diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc
index 46d54ed4233..426be18b029 100644
--- a/.cursor/rules/project-conventions.mdc
+++ b/.cursor/rules/project-conventions.mdc
@@ -15,7 +15,7 @@ This rule serves as high-level documentation for how you should write code in th
- Lucide Icons for icons
- OpenAI for AI chat
- Database: PostgreSQL
-- Jobs: Sidekiq + Redis
+- Jobs: SolidQueue
- External
- Payments: Stripe
- User bank data syncing: Plaid
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 442072675bb..5abfa84a097 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -9,7 +9,6 @@ x-rails-env: &rails_env
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
BUNDLE_PATH: /bundle
- REDIS_URL: redis://redis:6379/1
services:
app:
@@ -27,37 +26,16 @@ services:
<<: *rails_env
depends_on:
- db
- - redis
-
- worker:
- build:
- context: ..
- dockerfile: .devcontainer/Dockerfile
- volumes:
- - ..:/workspace:cached
- - bundle_cache:/bundle
- command: bundle exec sidekiq
- restart: unless-stopped
- environment:
- <<: *rails_env
- depends_on:
- - redis
-
- redis:
- image: redis:latest
- volumes:
- - redis-data:/data
- restart: unless-stopped
-
db:
image: postgres:latest
volumes:
- postgres-data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
restart: unless-stopped
environment:
<<: *db_env
volumes:
postgres-data:
- redis-data:
bundle_cache:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eae6bb5e342..2306ec69af4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,7 +80,6 @@ jobs:
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar
DATABASE_URL: postgres://postgres:postgres@localhost:5432
- REDIS_URL: redis://localhost:6379
RAILS_ENV: test
services:
@@ -93,12 +92,6 @@ jobs:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
- redis:
- image: redis
- ports:
- - 6379:6379
- options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
-
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
diff --git a/.ruby-version b/.ruby-version
index f9892605c75..4b4ea1b6883 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.4.4
+3.4.6
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 575ba82c55d..8dca8d4bac8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Common Development Commands
### Development Server
-- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)
+- `bin/dev` - Start development server (Rails, Solid queue, Tailwind CSS watcher)
- `bin/rails server` - Start Rails server only
- `bin/rails console` - Open Rails console
@@ -95,11 +95,11 @@ Two primary data ingestion methods:
- Custom field mapping with transformation rules
### Background Processing
-Sidekiq handles asynchronous tasks:
+SolidQueue handles asynchronous tasks:
- Account syncing (`SyncJob`)
- Import processing (`ImportJob`)
- AI chat responses (`AssistantResponseJob`)
-- Scheduled maintenance via sidekiq-cron
+- Scheduled maintenance via SolidQueue
### Frontend Architecture
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
diff --git a/Dockerfile b/Dockerfile
index 2248d827880..6864a5c8682 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
-ARG RUBY_VERSION=3.4.4
+ARG RUBY_VERSION=3.4.6
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
diff --git a/Gemfile b/Gemfile
index 9d4ac1fe962..45ff4c8702a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,7 +7,6 @@ gem "rails", "~> 7.2.2"
# Drivers
gem "pg", "~> 1.5"
-gem "redis", "~> 5.4"
# Deployment
gem "puma", ">= 5.0"
@@ -31,15 +30,12 @@ gem "lookbook", "2.3.11"
gem "hotwire_combobox"
# Background Jobs
-gem "sidekiq"
-gem "sidekiq-cron"
# Monitoring
gem "vernier"
gem "rack-mini-profiler"
gem "sentry-ruby"
gem "sentry-rails"
-gem "sentry-sidekiq"
gem "logtail-rails"
gem "skylight", groups: [ :production ]
@@ -122,3 +118,17 @@ group :test do
gem "climate_control"
gem "simplecov", require: false
end
+
+gem "rails_db", "~> 2.5"
+
+gem "seed_dump", "~> 3.3"
+
+gem "solid_cache", "~> 1.0"
+
+gem "sqlite3", "~> 2.7"
+
+gem "solid_queue", "~> 1.2"
+
+gem "solid_cable", "~> 3.0"
+
+gem "mission_control-jobs", "~> 1.1"
diff --git a/Gemfile.lock b/Gemfile.lock
index d85c4caac44..65a3fd597cd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -149,9 +149,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
- cronex (0.15.0)
- tzinfo
- unicode (>= 0.4.4.5)
css_parser (1.21.1)
addressable
csv (3.3.5)
@@ -293,6 +290,18 @@ GEM
faraday-follow_redirects
jwt (2.10.2)
base64
+ kaminari (1.2.2)
+ activesupport (>= 4.1.0)
+ kaminari-actionview (= 1.2.2)
+ kaminari-activerecord (= 1.2.2)
+ kaminari-core (= 1.2.2)
+ kaminari-actionview (1.2.2)
+ actionview
+ kaminari-core (= 1.2.2)
+ kaminari-activerecord (1.2.2)
+ activerecord
+ kaminari-core (= 1.2.2)
+ kaminari-core (1.2.2)
langfuse-ruby (0.1.4)
concurrent-ruby (~> 1.0)
faraday (>= 1.8, < 3.0)
@@ -351,6 +360,16 @@ GEM
logger
mini_mime (1.1.5)
minitest (5.25.5)
+ mission_control-jobs (1.1.0)
+ actioncable (>= 7.1)
+ actionpack (>= 7.1)
+ activejob (>= 7.1)
+ activerecord (>= 7.1)
+ importmap-rails (>= 1.2.1)
+ irb (~> 1.13)
+ railties (>= 7.1)
+ stimulus-rails
+ turbo-rails
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
@@ -492,6 +511,14 @@ GEM
rails-settings-cached (2.9.6)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
+ rails_db (2.5.0)
+ activerecord
+ csv
+ kaminari
+ rails (>= 5.0.0)
+ ransack (>= 2.3.2)
+ simple_form (>= 5.0.1)
+ terminal-table
railties (7.2.2.2)
actionpack (= 7.2.2.2)
activesupport (= 7.2.2.2)
@@ -502,6 +529,10 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
+ ransack (4.3.0)
+ activerecord (>= 6.1.5)
+ activesupport (>= 6.1.5)
+ i18n
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
@@ -511,10 +542,6 @@ GEM
erb
psych (>= 4.0.0)
redcarpet (3.6.1)
- redis (5.4.0)
- redis-client (>= 0.22.0)
- redis-client (0.25.0)
- connection_pool
regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5)
@@ -575,6 +602,9 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.1)
+ seed_dump (3.3.1)
+ activerecord (>= 4)
+ activesupport (>= 4)
selenium-webdriver (4.34.0)
base64 (~> 0.2)
logger (~> 1.4)
@@ -587,20 +617,9 @@ GEM
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
- sentry-sidekiq (5.26.0)
- sentry-ruby (~> 5.26.0)
- sidekiq (>= 3.0)
- sidekiq (8.0.5)
- connection_pool (>= 2.5.0)
- json (>= 2.9.0)
- logger (>= 1.6.2)
- rack (>= 3.1.0)
- redis-client (>= 0.23.2)
- sidekiq-cron (2.3.0)
- cronex (>= 0.13.0)
- fugit (~> 1.8, >= 1.11.1)
- globalid (>= 1.0.1)
- sidekiq (>= 6.5.0)
+ simple_form (5.3.1)
+ actionpack (>= 5.2)
+ activemodel (>= 5.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -610,7 +629,31 @@ GEM
skylight (6.0.4)
activesupport (>= 5.2.0)
smart_properties (1.17.0)
+ solid_cable (3.0.12)
+ actioncable (>= 7.2)
+ activejob (>= 7.2)
+ activerecord (>= 7.2)
+ railties (>= 7.2)
+ solid_cache (1.0.7)
+ activejob (>= 7.2)
+ activerecord (>= 7.2)
+ railties (>= 7.2)
+ solid_queue (1.2.1)
+ activejob (>= 7.1)
+ activerecord (>= 7.1)
+ concurrent-ruby (>= 1.3.1)
+ fugit (~> 1.11.0)
+ railties (>= 7.1)
+ thor (>= 1.3.1)
sorbet-runtime (0.5.12163)
+ sqlite3 (2.7.4-aarch64-linux-gnu)
+ sqlite3 (2.7.4-aarch64-linux-musl)
+ sqlite3 (2.7.4-arm-linux-gnu)
+ sqlite3 (2.7.4-arm-linux-musl)
+ sqlite3 (2.7.4-arm64-darwin)
+ sqlite3 (2.7.4-x86_64-darwin)
+ sqlite3 (2.7.4-x86_64-linux-gnu)
+ sqlite3 (2.7.4-x86_64-linux-musl)
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
@@ -641,7 +684,6 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unaccent (0.4.0)
- unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -727,6 +769,7 @@ DEPENDENCIES
logtail-rails
lookbook (= 2.3.11)
lucide-rails!
+ mission_control-jobs (~> 1.1)
mocha
octokit
omniauth (~> 2.1)
@@ -742,22 +785,24 @@ DEPENDENCIES
rack-mini-profiler
rails (~> 7.2.2)
rails-settings-cached
+ rails_db (~> 2.5)
redcarpet
- redis (~> 5.4)
rotp (~> 6.3)
rqrcode (~> 3.0)
rubocop-rails-omakase
ruby-lsp-rails
ruby-openai
rubyzip (~> 2.3)
+ seed_dump (~> 3.3)
selenium-webdriver
sentry-rails
sentry-ruby
- sentry-sidekiq
- sidekiq
- sidekiq-cron
simplecov
skylight
+ solid_cable (~> 3.0)
+ solid_cache (~> 1.0)
+ solid_queue (~> 1.2)
+ sqlite3 (~> 2.7)
stackprof
stimulus-rails
stripe
@@ -771,7 +816,7 @@ DEPENDENCIES
webmock
RUBY VERSION
- ruby 3.4.4p34
+ ruby 3.4.6p54
BUNDLED WITH
- 2.6.7
+ 2.7.2
diff --git a/Procfile.dev b/Procfile.dev
index eb6eadebd30..98923918ae4 100644
--- a/Procfile.dev
+++ b/Procfile.dev
@@ -1,3 +1,2 @@
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
css: bundle exec bin/rails tailwindcss:watch 2>/dev/null
-worker: bundle exec sidekiq
diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb
index 4f20b9346e3..06796605408 100644
--- a/app/controllers/api/v1/auth_controller.rb
+++ b/app/controllers/api/v1/auth_controller.rb
@@ -4,7 +4,6 @@ class AuthController < BaseController
include Invitable
skip_before_action :authenticate_request!
- skip_before_action :check_api_key_rate_limit
skip_before_action :log_api_access
def signup
diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb
index f176fff4b68..7c2bdf719c9 100644
--- a/app/controllers/api/v1/base_controller.rb
+++ b/app/controllers/api/v1/base_controller.rb
@@ -16,11 +16,8 @@ class Api::V1::BaseController < ApplicationController
before_action :force_json_format
# Use our custom authentication that supports both OAuth and API keys
before_action :authenticate_request!
- before_action :check_api_key_rate_limit
before_action :log_api_access
-
-
# Override Doorkeeper's default behavior to return JSON instead of redirecting
def doorkeeper_unauthorized_render_options(error: nil)
{ json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } }
@@ -98,55 +95,10 @@ def authenticate_api_key
@current_user = @api_key.user
@api_key.update_last_used!
@authentication_method = :api_key
- @rate_limiter = ApiRateLimiter.limit(@api_key)
setup_current_context_for_api
true
end
- # Check rate limits for API key authentication
- def check_api_key_rate_limit
- return unless @authentication_method == :api_key && @rate_limiter
-
- if @rate_limiter.rate_limit_exceeded?
- usage_info = @rate_limiter.usage_info
- render_rate_limit_exceeded(usage_info)
- return false
- end
-
- # Increment request count for successful API key requests
- @rate_limiter.increment_request_count!
-
- # Add rate limit headers to response
- add_rate_limit_headers(@rate_limiter.usage_info)
- end
-
- # Render rate limit exceeded response
- def render_rate_limit_exceeded(usage_info)
- response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
- response.headers["X-RateLimit-Remaining"] = "0"
- response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
- response.headers["Retry-After"] = usage_info[:reset_time].to_s
-
- Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests"
-
- render_json({
- error: "rate_limit_exceeded",
- message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.",
- details: {
- limit: usage_info[:rate_limit],
- current: usage_info[:current_count],
- reset_in_seconds: usage_info[:reset_time]
- }
- }, status: :too_many_requests)
- end
-
- # Add rate limit headers to successful responses
- def add_rate_limit_headers(usage_info)
- response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
- response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s
- response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
- end
-
# Render unauthorized response
def render_unauthorized
render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized)
diff --git a/app/controllers/api/v1/usage_controller.rb b/app/controllers/api/v1/usage_controller.rb
index b2a57df9e72..9d7bd4ee924 100644
--- a/app/controllers/api/v1/usage_controller.rb
+++ b/app/controllers/api/v1/usage_controller.rb
@@ -5,21 +5,12 @@ def show
case @authentication_method
when :api_key
- usage_info = @rate_limiter.usage_info
render_json({
api_key: {
name: @api_key.name,
scopes: @api_key.scopes,
last_used_at: @api_key.last_used_at,
created_at: @api_key.created_at
- },
- rate_limit: {
- tier: usage_info[:tier],
- limit: usage_info[:rate_limit],
- current_count: usage_info[:current_count],
- remaining: usage_info[:remaining],
- reset_in_seconds: usage_info[:reset_time],
- reset_at: Time.current + usage_info[:reset_time].seconds
}
})
when :oauth
diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb
index 60655094015..f2e69caf4c7 100644
--- a/app/controllers/concerns/onboardable.rb
+++ b/app/controllers/concerns/onboardable.rb
@@ -25,6 +25,7 @@ def redirectable_path?(path)
return false if path.starts_with?("/subscription")
return false if path.starts_with?("/onboarding")
return false if path.starts_with?("/users")
+ return false if path.starts_with?("/jobs")
return false if path.starts_with?("/api") # Exclude API endpoints from onboarding redirects
[
diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb
index 3631571aea9..a863f17026b 100644
--- a/app/controllers/concerns/self_hostable.rb
+++ b/app/controllers/concerns/self_hostable.rb
@@ -3,8 +3,6 @@ module SelfHostable
included do
helper_method :self_hosted?, :self_hosted_first_login?
-
- prepend_before_action :verify_self_host_config
end
private
@@ -15,29 +13,4 @@ def self_hosted?
def self_hosted_first_login?
self_hosted? && User.count.zero?
end
-
- def verify_self_host_config
- return unless self_hosted?
-
- # Special handling for Redis configuration error page
- if controller_name == "pages" && action_name == "redis_configuration_error"
- # If Redis is now working, redirect to home
- if redis_connected?
- redirect_to root_path, notice: "Redis is now configured properly! You can now setup your Sure application."
- end
-
- return
- end
-
- unless redis_connected?
- redirect_to redis_configuration_error_path
- end
- end
-
- def redis_connected?
- Redis.new.ping
- true
- rescue Redis::CannotConnectError
- false
- end
end
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index 07a7d10f0e1..e4cddb383c0 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -1,8 +1,6 @@
class PagesController < ApplicationController
include Periodable
- skip_authentication only: :redis_configuration_error
-
def dashboard
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.visible.with_attached_logo
@@ -66,10 +64,6 @@ def feedback
render layout: "settings"
end
- def redis_configuration_error
- render layout: "blank"
- end
-
private
def github_provider
Provider::Registry.get_provider(:github)
diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb
index 5a7738bb4d5..353cbd98903 100644
--- a/app/models/provider/twelve_data.rb
+++ b/app/models/provider/twelve_data.rb
@@ -48,29 +48,53 @@ def fetch_exchange_rate(from:, to:, date:)
end
rate = JSON.parse(response.body).dig("rate")
-
+ if rate.nil?
+ Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}, response: #{response.body}")
+ raise InvalidExchangeRateError.new("Could not fetch exchange rate for #{from}/#{to} on #{date}, response: #{response.body}")
+ end
Rate.new(date: date.to_date, from:, to:, rate: rate)
end
end
+ def fetch_exchange_cross_rates(from:, to:, start_date:, end_date:)
+ # Add a random delay to avoid rate limiting
+ sleep(rand(60..300))
+ response = client.get("#{base_url}/time_series/cross") do |req|
+ req.params["base"] = "#{from}"
+ req.params["quote"] = "#{to}"
+ req.params["start_date"] = start_date.to_s
+ req.params["end_date"] = end_date.to_s
+ req.params["interval"] = "1day"
+ end
+ data = JSON.parse(response.body).dig("values")
+ if data.nil?
+ error_message = parsed.dig("message") || "No data returned"
+ error_code = parsed.dig("code") || "unknown"
+ Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} between: #{start_date} and #{end_date}, response: #{response.body}, error: #{error_message} (code: #{error_code})")
+ raise InvalidExchangeRateError.new("Could not fetch exchange rates for #{from}/#{to} between #{start_date} and #{end_date}, response: #{response.body}")
+ end
+ data
+ end
+
+ def fetch_exchange_rates_internal(from:, to:, start_date:, end_date:)
+ response = client.get("#{base_url}/time_series") do |req|
+ req.params["symbol"] = "#{from}/#{to}"
+ req.params["start_date"] = start_date.to_s
+ req.params["end_date"] = end_date.to_s
+ req.params["interval"] = "1day"
+ end
+ parsed = JSON.parse(response.body)
+ if parsed.dig("code") == 404
+ error_message = parsed.dig("message") || "No data returned"
+ error_code = parsed.dig("code") || "unknown"
+ Rails.logger.warn("#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} between: #{start_date} and #{end_date}, response: #{response.body}, error: #{error_message} (code: #{error_code})")
+ fetch_exchange_cross_rates(from:, to:, start_date:, end_date:)
+ end
+ parsed.dig("values")
+ end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
with_provider_response do
- response = client.get("#{base_url}/time_series") do |req|
- req.params["symbol"] = "#{from}/#{to}"
- req.params["start_date"] = start_date.to_s
- req.params["end_date"] = end_date.to_s
- req.params["interval"] = "1day"
- end
-
- parsed = JSON.parse(response.body)
- data = parsed.dig("values")
-
- if data.nil?
- error_message = parsed.dig("message") || "No data returned"
- error_code = parsed.dig("code") || "unknown"
- raise InvalidExchangeRateError, "API error (code: #{error_code}): #{error_message}"
- end
-
+ data = fetch_exchange_rates_internal(from:, to:, start_date:, end_date:)
data.map do |resp|
rate = resp.dig("close")
date = resp.dig("datetime")
@@ -167,15 +191,13 @@ def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end
end
parsed = JSON.parse(response.body)
- values = parsed.dig("values")
-
- if values.nil?
+ if !parsed.dig("code").nil?
error_message = parsed.dig("message") || "No data returned"
error_code = parsed.dig("code") || "unknown"
- raise InvalidSecurityPriceError, "API error (code: #{error_code}): #{error_message}"
+ Rails.logger.warn("#{self.class.name} returned invalid price data for security #{symbol} between #{start_date} and #{end_date}.")
+ raise InvalidSecurityPriceError.new("Could not fetch security prices for #{symbol} between #{start_date} and #{end_date}, response: #{response.body}, error: #{error_message} (code: #{error_code})")
end
-
- values.map do |resp|
+ parsed.dig("values").map do |resp|
price = resp.dig("close")
date = resp.dig("datetime")
if price.nil?
@@ -207,7 +229,13 @@ def client
max: 2,
interval: 0.05,
interval_randomness: 0.5,
- backoff_factor: 2
+ backoff_factor: 2,
+ exceptions: [ Faraday::TooManyRequestsError, Faraday::RetriableResponse ],
+ retry_statuses: [ 429 ],
+ retry_block: ->(env, _, retries, _) {
+ # Sleep between 1-10 minutes when retrying
+ sleep(60 + rand(540))
+ }
})
faraday.request :json
diff --git a/app/models/sync.rb b/app/models/sync.rb
index 3e2abfe6ee4..3a299eb160a 100644
--- a/app/models/sync.rb
+++ b/app/models/sync.rb
@@ -61,7 +61,7 @@ def clean
def perform
Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do
- # This can happen on server restarts or if Sidekiq enqueues a duplicate job
+ # This can happen on server restarts or if ~~Sidekiq~~ SolidQueue enqueues a duplicate job
unless may_start?
Rails.logger.warn("Sync #{id} is not in a valid state (#{aasm.from_state}) to start. Skipping sync.")
return
@@ -159,7 +159,7 @@ def report_error(error)
def report_warnings
todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count
- if todays_sync_count > 10
+ if todays_sync_count > 100
Sentry.capture_exception(
Error.new("#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})"),
level: :warning
diff --git a/app/services/api_rate_limiter.rb b/app/services/api_rate_limiter.rb
deleted file mode 100644
index d3a771cf5c9..00000000000
--- a/app/services/api_rate_limiter.rb
+++ /dev/null
@@ -1,95 +0,0 @@
-class ApiRateLimiter
- # Rate limit tiers (requests per hour)
- RATE_LIMITS = {
- standard: 100,
- premium: 1000,
- enterprise: 10000
- }.freeze
-
- DEFAULT_TIER = :standard
-
- def initialize(api_key)
- @api_key = api_key
- @redis = Redis.new
- end
-
- # Check if the API key has exceeded its rate limit
- def rate_limit_exceeded?
- current_count >= rate_limit
- end
-
- # Increment the request count for this API key
- def increment_request_count!
- key = redis_key
- current_time = Time.current.to_i
- window_start = (current_time / 3600) * 3600 # Hourly window
-
- @redis.multi do |transaction|
- # Use a sliding window with hourly buckets
- transaction.hincrby(key, window_start.to_s, 1)
- transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window
- end
- end
-
- # Get current request count within the current hour
- def current_count
- key = redis_key
- current_time = Time.current.to_i
- window_start = (current_time / 3600) * 3600
-
- count = @redis.hget(key, window_start.to_s)
- count.to_i
- end
-
- # Get the rate limit for this API key's tier
- def rate_limit
- tier = determine_tier
- RATE_LIMITS[tier]
- end
-
- # Calculate seconds until the rate limit resets
- def reset_time
- current_time = Time.current.to_i
- next_window = ((current_time / 3600) + 1) * 3600
- next_window - current_time
- end
-
- # Get detailed usage information
- def usage_info
- {
- current_count: current_count,
- rate_limit: rate_limit,
- remaining: [ rate_limit - current_count, 0 ].max,
- reset_time: reset_time,
- tier: determine_tier
- }
- end
-
- # Class method to get usage for an API key without incrementing
- def self.usage_for(api_key)
- limit(api_key).usage_info
- end
-
- def self.limit(api_key)
- if Rails.application.config.app_mode.self_hosted?
- # Use NoopApiRateLimiter for self-hosted mode
- # This means no rate limiting is applied
- NoopApiRateLimiter.new(api_key)
- else
- new(api_key)
- end
- end
-
- private
-
- def redis_key
- "api_rate_limit:#{@api_key.id}"
- end
-
- def determine_tier
- # For now, all API keys are standard tier
- # This can be extended later to support different tiers based on user subscription
- # or API key configuration
- DEFAULT_TIER
- end
-end
diff --git a/app/services/noop_api_rate_limiter.rb b/app/services/noop_api_rate_limiter.rb
deleted file mode 100644
index 116b6537ed8..00000000000
--- a/app/services/noop_api_rate_limiter.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-class NoopApiRateLimiter
- def initialize(api_key)
- @api_key = api_key
- end
-
- def rate_limit_exceeded?
- false
- end
-
- def increment_request_count!
- # No operation
- end
-
- def current_count
- 0
- end
-
- def rate_limit
- Float::INFINITY
- end
-
- def reset_time
- 0
- end
-
- def usage_info
- {
- current_count: 0,
- rate_limit: Float::INFINITY,
- remaining: Float::INFINITY,
- reset_time: 0,
- tier: :noop
- }
- end
-
- def self.usage_for(api_key)
- new(api_key).usage_info
- end
-end
diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb
index a09a8250fa3..a6ad6b43a5e 100644
--- a/app/views/impersonation_sessions/_super_admin_bar.html.erb
+++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb
@@ -4,7 +4,7 @@
Super Admin
- <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
+ <%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
diff --git a/app/views/pages/redis_configuration_error.html.erb b/app/views/pages/redis_configuration_error.html.erb
deleted file mode 100644
index 53c43387f4a..00000000000
--- a/app/views/pages/redis_configuration_error.html.erb
+++ /dev/null
@@ -1,59 +0,0 @@
-<% content_for :title, "Redis Configuration Required - Sure" %>
-
-
-
-
-
-
-
- <%= icon "alert-triangle", class: "w-8 h-8 text-red-600" %>
-
-
Redis Configuration Required
-
Your self-hosted Sure installation needs Redis to be properly configured.
-
-
-
-
-
-
- <%= icon "info", class: "w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0" %>
-
-
Why is Redis required?
-
Sure uses Redis to power Sidekiq background jobs for tasks like syncing account data, processing imports, and other background operations that keep your financial data up to date.
-
-
-
-
-
-
- <%= render DS::Link.new(
- text: "View Setup Guide",
- href: "https://github.com/we-promise/sure/blob/main/docs/hosting/docker.md",
- variant: "primary",
- size: "lg",
- icon: "external-link",
- full_width: true,
- target: "_blank",
- rel: "noopener noreferrer"
- ) %>
-
Follow our complete Docker setup guide to configure Redis
-
-
-
-
-
-
-
Once you've configured Redis, refresh this page to continue.
- <%= render DS::Button.new(
- text: "Refresh Page",
- variant: "secondary",
- icon: "refresh-cw",
- type: "button",
- full_width: true,
- onclick: "window.location.reload()"
- ) %>
-
-
-
-
-
diff --git a/bin/jobs b/bin/jobs
new file mode 100755
index 00000000000..dcf59f309ae
--- /dev/null
+++ b/bin/jobs
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/environment"
+require "solid_queue/cli"
+
+SolidQueue::Cli.start(ARGV)
diff --git a/compose.example.yml b/compose.example.yml
index 01a97368b0d..172461178d1 100644
--- a/compose.example.yml
+++ b/compose.example.yml
@@ -40,7 +40,6 @@ x-rails-env: &rails_env
RAILS_ASSUME_SSL: "false"
DB_HOST: db
DB_PORT: 5432
- REDIS_URL: redis://redis:6379/1
# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this.
OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN}
@@ -57,24 +56,6 @@ services:
depends_on:
db:
condition: service_healthy
- redis:
- condition: service_healthy
- networks:
- - sure_net
-
- worker:
- image: ghcr.io/we-promise/sure:latest
- command: bundle exec sidekiq
- volumes:
- - app-storage:/rails/storage
- restart: unless-stopped
- depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
- environment:
- <<: *rails_env
networks:
- sure_net
@@ -93,23 +74,9 @@ services:
networks:
- sure_net
- redis:
- image: redis:latest
- restart: unless-stopped
- volumes:
- - redis-data:/data
- healthcheck:
- test: [ "CMD", "redis-cli", "ping" ]
- interval: 5s
- timeout: 5s
- retries: 5
- networks:
- - sure_net
-
volumes:
app-storage:
postgres-data:
- redis-data:
networks:
sure_net:
diff --git a/config/application.rb b/config/application.rb
index 77d071173e1..9a2206ea717 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -41,5 +41,7 @@ class Application < Rails::Application
# Enable Rack::Attack middleware for API rate limiting
config.middleware.use Rack::Attack
+ MissionControl::Jobs.http_basic_auth_user = "mission_control_jobs"
+ MissionControl::Jobs.http_basic_auth_password = ENV["MISSION_CONTROL_JOBS_PASSWORD"] || "mission_control_jobs"
end
end
diff --git a/config/cable.yml b/config/cable.yml
index fc5a1eb8efa..7ca155ef873 100644
--- a/config/cable.yml
+++ b/config/cable.yml
@@ -1,10 +1,21 @@
+# Async adapter only works within the same process, so for manually triggering cable updates from a console,
+# and seeing results in the browser, you must do so from the web console (running inside the dev process),
+# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
+# to make the web console appear.
+
+default: &default
+ adapter: solid_cable
+ connects_to:
+ database:
+ writing: cable
+ polling_interval: 0.1.seconds
+ message_retention: 1.day
+
development:
- adapter: async
+ <<: *default
test:
adapter: test
production:
- adapter: redis
- url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
- channel_prefix: sure_production
+ <<: *default
diff --git a/config/cache.yml b/config/cache.yml
new file mode 100644
index 00000000000..fdb4f759653
--- /dev/null
+++ b/config/cache.yml
@@ -0,0 +1,17 @@
+default: &default
+ store_options:
+ # Cap age of oldest cache entry to fulfill retention policies
+ max_age: <%= 60.days.to_i %>
+ max_size: <%= 1.gigabytes %>
+ namespace: <%= Rails.env %>
+
+development: &development
+ <<: *default
+ database: cache
+
+test:
+ <<: *default
+
+production: &production
+ <<: *default
+ database: cache
\ No newline at end of file
diff --git a/config/database.yml b/config/database.yml
index 5b249238a33..9d8081311e2 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -8,13 +8,39 @@ default: &default
password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %>
development:
- <<: *default
- database: <%= ENV.fetch("POSTGRES_DB") { "sure_development" } %>
+ primary:
+ <<: *default
+ database: <%= ENV.fetch("POSTGRES_DB") { "maybe_development" } %>
+ cache:
+ adapter: sqlite3
+ database: storage/development_cache.sqlite3
+ migrations_paths: db/cache_migrate
+ queue:
+ adapter: sqlite3
+ database: storage/development_queue.sqlite3
+ migrations_paths: db/queue_migrate
+ cable:
+ adapter: sqlite3
+ database: storage/development_cable.sqlite3
+ migrations_paths: db/cable_migrate
test:
<<: *default
- database: <%= ENV.fetch("POSTGRES_DB") { "sure_test" } %>
+ database: <%= ENV.fetch("POSTGRES_DB") { "maybe_test" } %>
production:
- <<: *default
- database: <%= ENV.fetch("POSTGRES_DB") { "sure_production" } %>
+ primary:
+ <<: *default
+ database: <%= ENV.fetch("POSTGRES_DB") { "maybe_production" } %>
+ cache:
+ adapter: sqlite3
+ database: storage/production_cache.sqlite3
+ migrations_paths: db/cache_migrate
+ queue:
+ adapter: sqlite3
+ database: storage/production_queue.sqlite3
+ migrations_paths: db/queue_migrate
+ cable:
+ adapter: sqlite3
+ database: storage/production_cable.sqlite3
+ migrations_paths: db/cable_migrate
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 553da47e3e8..f8c5242ca61 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -67,6 +67,9 @@
config.assets.quiet = true
config.active_record.verbose_query_logs = true
config.active_job.verbose_enqueue_logs = true
+ config.active_job.queue_adapter = :solid_queue
+ config.solid_queue.connects_to = { database: { writing: :queue } }
+ config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT)
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 671b5239fe0..31f9a0a59b1 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -69,9 +69,7 @@
# want to log everything, set the level to "debug".
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
- if ENV["CACHE_REDIS_URL"].present?
- config.cache_store = :redis_cache_store, { url: ENV["CACHE_REDIS_URL"] }
- end
+ config.cache_store = :solid_cache_store
config.action_mailer.perform_caching = false
config.action_mailer.deliver_later_queue_name = :high_priority
@@ -107,6 +105,6 @@
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
- # set REDIS_URL for Sidekiq to use Redis
- config.active_job.queue_adapter = :sidekiq
+ config.active_job.queue_adapter = :solid_queue
+ config.solid_queue.connects_to = { database: { writing: :queue } }
end
diff --git a/config/initializers/rails_db.rb b/config/initializers/rails_db.rb
new file mode 100644
index 00000000000..359d9d7b79f
--- /dev/null
+++ b/config/initializers/rails_db.rb
@@ -0,0 +1,30 @@
+if Object.const_defined?("RailsDb")
+ RailsDb.setup do |config|
+ # # enabled or not
+ config.enabled = true
+
+ # # automatic engine routes mounting
+ config.automatic_routes_mount = true
+
+ # set tables which you want to hide ONLY
+ # config.black_list_tables = [ "users", "accounts" ]
+
+ # set tables which you want to show ONLY
+ # config.white_list_tables = [ "posts", "comments" ]
+
+ # # Enable http basic authentication
+ config.http_basic_authentication_enabled = true
+
+ # # Enable http basic authentication
+ config.http_basic_authentication_user_name = "rails_db"
+
+ # # Enable http basic authentication
+ config.http_basic_authentication_password = ENV.fetch("RAILS_DB_PASSWORD", "rails_db")
+
+ # # Enable verify access proc
+ # config.verify_access_proc = proc { |controller| true }
+
+ # # Sandbox mode (only read-only operations)
+ # config.sandbox = false
+ end
+end
diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb
index d964a09b85d..1a6e9143f78 100644
--- a/config/initializers/sentry.rb
+++ b/config/initializers/sentry.rb
@@ -16,5 +16,7 @@
config.profiles_sample_rate = 0.25
config.profiler_class = Sentry::Vernier::Profiler
+ config.enable_logs = true
+ config.enabled_patches = [ :logger, :http, :puma ]
end
end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
deleted file mode 100644
index b80a8fddf23..00000000000
--- a/config/initializers/sidekiq.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "sidekiq/web"
-
-if Rails.env.production?
- Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|
- configured_username = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_USERNAME", "sure"))
- configured_password = ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_WEB_PASSWORD", "sure"))
-
- ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) &&
- ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password)
- end
-end
-
-Sidekiq::Cron.configure do |config|
- # 10 min "catch-up" window in case worker process is re-deploying when cron tick occurs
- config.reschedule_grace_period = 600
-end
diff --git a/config/puma.rb b/config/puma.rb
index 47a2362e275..160bbef9187 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -47,6 +47,8 @@
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
+plugin :solid_queue
+
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
if rails_env == "development"
diff --git a/config/queue.yml b/config/queue.yml
new file mode 100644
index 00000000000..9eace59c41e
--- /dev/null
+++ b/config/queue.yml
@@ -0,0 +1,18 @@
+default: &default
+ dispatchers:
+ - polling_interval: 1
+ batch_size: 500
+ workers:
+ - queues: "*"
+ threads: 3
+ processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
+ polling_interval: 0.1
+
+development:
+ <<: *default
+
+test:
+ <<: *default
+
+production:
+ <<: *default
diff --git a/config/recurring.yml b/config/recurring.yml
new file mode 100644
index 00000000000..1b5043f2ea5
--- /dev/null
+++ b/config/recurring.yml
@@ -0,0 +1,34 @@
+# examples:
+# periodic_cleanup:
+# class: CleanSoftDeletedRecordsJob
+# queue: background
+# args: [ 1000, { batch_size: 500 } ]
+# schedule: every hour
+# periodic_cleanup_with_command:
+# command: "SoftDeletedRecord.due.delete_all"
+# priority: 2
+# schedule: at 5am every day
+
+development:
+ clear_solid_queue_finished_jobs:
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
+ schedule: every hour at minute 12
+
+ import_market_data:
+ class: "ImportMarketDataJob"
+ queue: "scheduled"
+ args: { mode: "full", clear_cache: false }
+ schedule: "0 22 * * 1-5" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday
+ description: "Imports market data daily at 5:00 PM EST (1 hour after market close)"
+
+ clean_syncs:
+ schedule: "0 * * * *" # every hour
+ class: "SyncCleanerJob"
+ queue: "scheduled"
+ description: "Cleans up stale syncs"
+
+ run_security_health_checks:
+ schedule: "0 2 * * 1-5" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday
+ class: "SecurityHealthCheckJob"
+ queue: "scheduled"
+ description: "Runs security health checks to detect issues with security data"
diff --git a/config/routes.rb b/config/routes.rb
index fbdecead056..f5a8547e278 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,3 @@
-require "sidekiq/web"
-require "sidekiq/cron/web"
-
Rails.application.routes.draw do
use_doorkeeper
# MFA routes
@@ -12,8 +9,7 @@
mount Lookbook::Engine, at: "/design-system"
- # Uses basic auth - see config/initializers/sidekiq.rb
- mount Sidekiq::Web => "/sidekiq"
+ mount MissionControl::Jobs::Engine, at: "/jobs"
# AI chats
resources :chats do
@@ -303,8 +299,6 @@
post "stripe"
end
- get "redis-configuration-error", to: "pages#redis_configuration_error"
-
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
diff --git a/db/cable_schema.rb b/db/cable_schema.rb
new file mode 100644
index 00000000000..de80d1068a8
--- /dev/null
+++ b/db/cable_schema.rb
@@ -0,0 +1,23 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.2].define(version: 1) do
+ create_table "solid_cable_messages", force: :cascade do |t|
+ t.binary "channel", limit: 1024, null: false
+ t.binary "payload", limit: 536870912, null: false
+ t.datetime "created_at", null: false
+ t.integer "channel_hash", limit: 8, null: false
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
+ end
+end
diff --git a/db/cache_schema.rb b/db/cache_schema.rb
new file mode 100644
index 00000000000..fec220f6d64
--- /dev/null
+++ b/db/cache_schema.rb
@@ -0,0 +1,24 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.2].define(version: 1) do
+ create_table "solid_cache_entries", force: :cascade do |t|
+ t.binary "key", limit: 1024, null: false
+ t.binary "value", limit: 536870912, null: false
+ t.datetime "created_at", null: false
+ t.integer "key_hash", limit: 8, null: false
+ t.integer "byte_size", limit: 4, null: false
+ t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
+ t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
+ t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
+ end
+end
diff --git a/db/queue_schema.rb b/db/queue_schema.rb
new file mode 100644
index 00000000000..df3358c2874
--- /dev/null
+++ b/db/queue_schema.rb
@@ -0,0 +1,141 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.2].define(version: 1) do
+ create_table "solid_queue_blocked_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "queue_name", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "concurrency_key", null: false
+ t.datetime "expires_at", null: false
+ t.datetime "created_at", null: false
+ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
+ t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_claimed_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.bigint "process_id"
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
+ t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
+ end
+
+ create_table "solid_queue_failed_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.text "error"
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_jobs", force: :cascade do |t|
+ t.string "queue_name", null: false
+ t.string "class_name", null: false
+ t.text "arguments"
+ t.integer "priority", default: 0, null: false
+ t.string "active_job_id"
+ t.datetime "scheduled_at"
+ t.datetime "finished_at"
+ t.string "concurrency_key"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
+ t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
+ t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
+ end
+
+ create_table "solid_queue_pauses", force: :cascade do |t|
+ t.string "queue_name", null: false
+ t.datetime "created_at", null: false
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
+ end
+
+ create_table "solid_queue_processes", force: :cascade do |t|
+ t.string "kind", null: false
+ t.datetime "last_heartbeat_at", null: false
+ t.bigint "supervisor_id"
+ t.integer "pid", null: false
+ t.string "hostname"
+ t.text "metadata"
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
+ t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
+ end
+
+ create_table "solid_queue_ready_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "queue_name", null: false
+ t.integer "priority", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
+ t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
+ t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
+ end
+
+ create_table "solid_queue_recurring_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "task_key", null: false
+ t.datetime "run_at", null: false
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
+ t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
+ end
+
+ create_table "solid_queue_recurring_tasks", force: :cascade do |t|
+ t.string "key", null: false
+ t.string "schedule", null: false
+ t.string "command", limit: 2048
+ t.string "class_name"
+ t.text "arguments"
+ t.string "queue_name"
+ t.integer "priority", default: 0
+ t.boolean "static", default: true, null: false
+ t.text "description"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
+ end
+
+ create_table "solid_queue_scheduled_executions", force: :cascade do |t|
+ t.bigint "job_id", null: false
+ t.string "queue_name", null: false
+ t.integer "priority", default: 0, null: false
+ t.datetime "scheduled_at", null: false
+ t.datetime "created_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
+ t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
+ end
+
+ create_table "solid_queue_semaphores", force: :cascade do |t|
+ t.string "key", null: false
+ t.integer "value", default: 1, null: false
+ t.datetime "expires_at", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
+ t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
+ end
+
+ add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+end
diff --git a/db/schema.rb b/db/schema.rb
index 955a0cfd343..8724b0deaf3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -254,8 +254,11 @@
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))"
t.index ["account_id"], name: "index_entries_on_account_id"
+ t.index ["amount"], name: "index_entries_on_amount"
t.index ["date"], name: "index_entries_on_date"
+ t.index ["entryable_id", "entryable_type"], name: "index_entries_on_entryable"
t.index ["entryable_type"], name: "index_entries_on_entryable_type"
+ t.index ["excluded"], name: "index_entries_on_excluded"
t.index ["import_id"], name: "index_entries_on_import_id"
end
@@ -266,6 +269,7 @@
t.date "date", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.index ["date", "from_currency", "to_currency"], name: "index_exchange_rates_on_date_and_currencies"
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
t.index ["from_currency"], name: "index_exchange_rates_on_from_currency"
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
@@ -874,6 +878,7 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["tag_id"], name: "index_taggings_on_tag_id"
+ t.index ["taggable_id", "taggable_type"], name: "index_taggings_on_taggable_id_and_type"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
end
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 00000000000..af2d736dd2e
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,29 @@
+x-db-env: &db_env
+ POSTGRES_USER: ${POSTGRES_USER:-maybe_user}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-maybe_password}
+ POSTGRES_DB: ${POSTGRES_DB:-maybe-development}
+
+services:
+ db:
+ image: postgres:16
+ restart: unless-stopped
+ volumes:
+ - maybe-dev-postgres-data:/var/lib/postgresql/data
+ ports:
+ - 5432:5432
+ environment:
+ <<: *db_env
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - sure_net
+
+volumes:
+ maybe-dev-postgres-data:
+
+networks:
+ sure_net:
+ driver: bridge
diff --git a/docs/api/chats.md b/docs/api/chats.md
index 3bc8ea43668..ff7a3ca8512 100644
--- a/docs/api/chats.md
+++ b/docs/api/chats.md
@@ -221,7 +221,6 @@ Common error codes:
- `forbidden` - Insufficient permissions or AI not enabled
- `not_found` - Resource not found
- `unprocessable_entity` - Invalid request data
-- `rate_limit_exceeded` - Too many requests
## Rate Limits
diff --git a/test/controllers/api/v1/base_controller_test.rb b/test/controllers/api/v1/base_controller_test.rb
index af9e7066dba..036b356c7cf 100644
--- a/test/controllers/api/v1/base_controller_test.rb
+++ b/test/controllers/api/v1/base_controller_test.rb
@@ -22,14 +22,6 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest
display_key: @plain_api_key,
scopes: [ "read_write" ]
)
-
- # Clear any existing rate limit data
- Redis.new.del("api_rate_limit:#{@api_key.id}")
- end
-
- teardown do
- # Clean up Redis data after each test
- Redis.new.del("api_rate_limit:#{@api_key.id}")
end
test "should require authentication" do
@@ -325,106 +317,6 @@ class Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest
assert_equal "forbidden", response_body["error"]
end
- test "should include rate limit headers on successful API key requests" do
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
-
- assert_response :success
- assert_not_nil response.headers["X-RateLimit-Limit"]
- assert_not_nil response.headers["X-RateLimit-Remaining"]
- assert_not_nil response.headers["X-RateLimit-Reset"]
-
- assert_equal "100", response.headers["X-RateLimit-Limit"]
- assert_equal "99", response.headers["X-RateLimit-Remaining"]
- end
-
- test "should increment rate limit count with each request" do
- # First request
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- assert_response :success
- assert_equal "99", response.headers["X-RateLimit-Remaining"]
-
- # Second request
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- assert_response :success
- assert_equal "98", response.headers["X-RateLimit-Remaining"]
- end
-
- test "should return 429 when rate limit exceeded" do
- # Make 100 requests to exhaust the rate limit
- 100.times do
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- assert_response :success
- end
-
- # 101st request should be rate limited
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- assert_response :too_many_requests
-
- response_body = JSON.parse(response.body)
- assert_equal "rate_limit_exceeded", response_body["error"]
- assert_includes response_body["message"], "Rate limit exceeded"
-
- # Check response headers
- assert_equal "100", response.headers["X-RateLimit-Limit"]
- assert_equal "0", response.headers["X-RateLimit-Remaining"]
- assert_not_nil response.headers["X-RateLimit-Reset"]
- assert_not_nil response.headers["Retry-After"]
- end
-
- test "should not apply rate limiting to OAuth requests" do
- # This would need to be implemented based on your OAuth setup
- # For now, just verify that requests without API keys don't trigger rate limiting
- get "/api/v1/test"
- assert_response :unauthorized
-
- # Should not have rate limit headers for unauthorized requests
- assert_nil response.headers["X-RateLimit-Limit"]
- end
-
- test "should provide detailed rate limit information in 429 response" do
- # Exhaust the rate limit
- 100.times do
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- end
-
- # Make the rate-limited request
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- assert_response :too_many_requests
-
- response_body = JSON.parse(response.body)
- assert_equal "rate_limit_exceeded", response_body["error"]
- assert response_body["details"]["limit"] == 100
- assert response_body["details"]["current"] >= 100
- assert response_body["details"]["reset_in_seconds"] > 0
- end
-
- test "rate limiting should be per API key" do
- # Create a second user for independent API keys
- other_user = users(:family_member)
- other_api_key = ApiKey.create!(
- user: other_user,
- name: "Other Test API Key",
- scopes: [ "read" ],
- display_key: "other_rate_test_#{SecureRandom.hex(8)}"
- )
-
- begin
- # Make 50 requests with first API key
- 50.times do
- get "/api/v1/test", headers: { "X-Api-Key" => @plain_api_key }
- assert_response :success
- end
-
- # Should still be able to make requests with second API key
- get "/api/v1/test", headers: { "X-Api-Key" => other_api_key.display_key }
- assert_response :success
- assert_equal "99", response.headers["X-RateLimit-Remaining"]
- ensure
- Redis.new.del("api_rate_limit:#{other_api_key.id}")
- other_api_key.destroy
- end
- end
-
private
def capture_log(&block)
diff --git a/test/controllers/api/v1/transactions_controller_test.rb b/test/controllers/api/v1/transactions_controller_test.rb
index b028562a632..dbd355e2d11 100644
--- a/test/controllers/api/v1/transactions_controller_test.rb
+++ b/test/controllers/api/v1/transactions_controller_test.rb
@@ -27,10 +27,6 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
display_key: "test_ro_#{SecureRandom.hex(8)}",
source: "mobile" # Use different source to allow multiple keys
)
-
- # Clear any existing rate limit data
- Redis.new.del("api_rate_limit:#{@api_key.id}")
- Redis.new.del("api_rate_limit:#{@read_only_api_key.id}")
end
# INDEX action tests
diff --git a/test/controllers/api/v1/usage_controller_test.rb b/test/controllers/api/v1/usage_controller_test.rb
index 27235826139..e169d51b385 100644
--- a/test/controllers/api/v1/usage_controller_test.rb
+++ b/test/controllers/api/v1/usage_controller_test.rb
@@ -12,14 +12,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
scopes: [ "read" ],
display_key: "usage_test_#{SecureRandom.hex(8)}"
)
-
- # Clear any existing rate limit data
- Redis.new.del("api_rate_limit:#{@api_key.id}")
- end
-
- teardown do
- # Clean up Redis data after each test
- Redis.new.del("api_rate_limit:#{@api_key.id}")
end
test "should return usage information for API key authentication" do
@@ -40,14 +32,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
assert_equal [ "read" ], response_body["api_key"]["scopes"]
assert_not_nil response_body["api_key"]["last_used_at"]
assert_not_nil response_body["api_key"]["created_at"]
-
- # Check rate limit information
- assert_equal "standard", response_body["rate_limit"]["tier"]
- assert_equal 100, response_body["rate_limit"]["limit"]
- assert_equal 4, response_body["rate_limit"]["current_count"] # 3 test requests + 1 usage request
- assert_equal 96, response_body["rate_limit"]["remaining"]
- assert response_body["rate_limit"]["reset_in_seconds"] > 0
- assert_not_nil response_body["rate_limit"]["reset_at"]
end
test "should require read scope for usage endpoint" do
@@ -68,7 +52,6 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
response_body = JSON.parse(response.body)
assert_equal "insufficient_scope", response_body["error"]
ensure
- Redis.new.del("api_rate_limit:#{api_key_no_read.id}")
api_key_no_read.destroy
end
end
@@ -79,58 +62,4 @@ class Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest
get "/api/v1/usage"
assert_response :unauthorized
end
-
- test "should update usage count when accessing usage endpoint" do
- # Check initial state
- get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :success
-
- response_body = JSON.parse(response.body)
- first_count = response_body["rate_limit"]["current_count"]
-
- # Make another usage request
- get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :success
-
- response_body = JSON.parse(response.body)
- second_count = response_body["rate_limit"]["current_count"]
-
- assert_equal first_count + 1, second_count
- end
-
- test "should include rate limit headers in usage response" do
- get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :success
-
- assert_not_nil response.headers["X-RateLimit-Limit"]
- assert_not_nil response.headers["X-RateLimit-Remaining"]
- assert_not_nil response.headers["X-RateLimit-Reset"]
-
- assert_equal "100", response.headers["X-RateLimit-Limit"]
- assert_equal "99", response.headers["X-RateLimit-Remaining"]
- end
-
- test "should work correctly when approaching rate limit" do
- # Make 98 requests to get close to the limit
- 98.times do
- get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :success
- end
-
- # Check usage - this should be request 99
- get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :success
-
- response_body = JSON.parse(response.body)
- assert_equal 99, response_body["rate_limit"]["current_count"]
- assert_equal 1, response_body["rate_limit"]["remaining"]
-
- # One more request should hit the limit
- get "/api/v1/test", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :success
-
- # Now we should be rate limited
- get "/api/v1/usage", headers: { "X-Api-Key" => @api_key.display_key }
- assert_response :too_many_requests
- end
end
diff --git a/test/services/api_rate_limiter_test.rb b/test/services/api_rate_limiter_test.rb
deleted file mode 100644
index 8afc6bb9a8e..00000000000
--- a/test/services/api_rate_limiter_test.rb
+++ /dev/null
@@ -1,138 +0,0 @@
-require "test_helper"
-
-class ApiRateLimiterTest < ActiveSupport::TestCase
- setup do
- @user = users(:family_admin)
- # Destroy any existing active API keys for this user
- @user.api_keys.active.destroy_all
-
- @api_key = ApiKey.create!(
- user: @user,
- name: "Rate Limiter Test Key",
- scopes: [ "read" ],
- display_key: "rate_limiter_test_#{SecureRandom.hex(8)}"
- )
- @rate_limiter = ApiRateLimiter.new(@api_key)
-
- # Clear any existing rate limit data
- Redis.new.del("api_rate_limit:#{@api_key.id}")
- end
-
- teardown do
- # Clean up Redis data after each test
- Redis.new.del("api_rate_limit:#{@api_key.id}")
- end
-
- test "should have default rate limit" do
- assert_equal 100, @rate_limiter.rate_limit
- end
-
- test "should start with zero request count" do
- assert_equal 0, @rate_limiter.current_count
- end
-
- test "should not be rate limited initially" do
- assert_not @rate_limiter.rate_limit_exceeded?
- end
-
- test "should increment request count" do
- assert_equal 0, @rate_limiter.current_count
-
- @rate_limiter.increment_request_count!
- assert_equal 1, @rate_limiter.current_count
-
- @rate_limiter.increment_request_count!
- assert_equal 2, @rate_limiter.current_count
- end
-
- test "should be rate limited when exceeding limit" do
- # Simulate reaching the rate limit
- 100.times { @rate_limiter.increment_request_count! }
-
- assert_equal 100, @rate_limiter.current_count
- assert @rate_limiter.rate_limit_exceeded?
- end
-
- test "should provide correct usage info" do
- 5.times { @rate_limiter.increment_request_count! }
-
- usage_info = @rate_limiter.usage_info
-
- assert_equal 5, usage_info[:current_count]
- assert_equal 100, usage_info[:rate_limit]
- assert_equal 95, usage_info[:remaining]
- assert_equal :standard, usage_info[:tier]
- assert usage_info[:reset_time] > 0
- assert usage_info[:reset_time] <= 3600
- end
-
- test "should calculate remaining requests correctly" do
- 10.times { @rate_limiter.increment_request_count! }
-
- usage_info = @rate_limiter.usage_info
- assert_equal 90, usage_info[:remaining]
- end
-
- test "should have zero remaining when at limit" do
- 100.times { @rate_limiter.increment_request_count! }
-
- usage_info = @rate_limiter.usage_info
- assert_equal 0, usage_info[:remaining]
- end
-
- test "should have zero remaining when over limit" do
- 105.times { @rate_limiter.increment_request_count! }
-
- usage_info = @rate_limiter.usage_info
- assert_equal 0, usage_info[:remaining]
- end
-
- test "class method usage_for should work without incrementing" do
- 5.times { @rate_limiter.increment_request_count! }
-
- usage_info = ApiRateLimiter.usage_for(@api_key)
- assert_equal 5, usage_info[:current_count]
-
- # Should not increment when just checking usage
- usage_info_again = ApiRateLimiter.usage_for(@api_key)
- assert_equal 5, usage_info_again[:current_count]
- end
-
- test "should handle multiple API keys separately" do
- # Create a different user for the second API key
- other_user = users(:family_member)
- other_api_key = ApiKey.create!(
- user: other_user,
- name: "Other API Key",
- scopes: [ "read_write" ],
- display_key: "rate_limiter_other_#{SecureRandom.hex(8)}"
- )
-
- other_rate_limiter = ApiRateLimiter.new(other_api_key)
-
- @rate_limiter.increment_request_count!
- other_rate_limiter.increment_request_count!
- other_rate_limiter.increment_request_count!
-
- assert_equal 1, @rate_limiter.current_count
- assert_equal 2, other_rate_limiter.current_count
- ensure
- Redis.new.del("api_rate_limit:#{other_api_key.id}")
- other_api_key.destroy
- end
-
- test "should calculate reset time correctly" do
- reset_time = @rate_limiter.reset_time
-
- # Reset time should be within the current hour
- assert reset_time > 0
- assert reset_time <= 3600
-
- # Should be roughly the time until the next hour
- current_time = Time.current.to_i
- next_window = ((current_time / 3600) + 1) * 3600
- expected_reset = next_window - current_time
-
- assert_in_delta expected_reset, reset_time, 1
- end
-end
diff --git a/test/services/noop_api_rate_limiter_test.rb b/test/services/noop_api_rate_limiter_test.rb
deleted file mode 100644
index 9c7105b12c8..00000000000
--- a/test/services/noop_api_rate_limiter_test.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require "test_helper"
-
-class NoopApiRateLimiterTest < ActiveSupport::TestCase
- setup do
- @user = users(:family_admin)
- # Clean up any existing API keys for this user to ensure tests start fresh
- @user.api_keys.destroy_all
-
- @api_key = ApiKey.create!(
- user: @user,
- name: "Noop Rate Limiter Test Key",
- scopes: [ "read" ],
- display_key: "noop_rate_limiter_test_#{SecureRandom.hex(8)}"
- )
- @rate_limiter = NoopApiRateLimiter.new(@api_key)
- end
-
- test "should never be rate limited" do
- assert_not @rate_limiter.rate_limit_exceeded?
- end
-
- test "should not increment request count" do
- @rate_limiter.increment_request_count!
- assert_equal 0, @rate_limiter.current_count
- end
-
- test "should always have zero request count" do
- assert_equal 0, @rate_limiter.current_count
- end
-
- test "should have infinite rate limit" do
- assert_equal Float::INFINITY, @rate_limiter.rate_limit
- end
-
- test "should have zero reset time" do
- assert_equal 0, @rate_limiter.reset_time
- end
-
- test "should provide correct usage info" do
- usage_info = @rate_limiter.usage_info
-
- assert_equal 0, usage_info[:current_count]
- assert_equal Float::INFINITY, usage_info[:rate_limit]
- assert_equal Float::INFINITY, usage_info[:remaining]
- assert_equal 0, usage_info[:reset_time]
- assert_equal :noop, usage_info[:tier]
- end
-
- test "class method usage_for should work" do
- usage_info = NoopApiRateLimiter.usage_for(@api_key)
-
- assert_equal 0, usage_info[:current_count]
- assert_equal Float::INFINITY, usage_info[:rate_limit]
- assert_equal Float::INFINITY, usage_info[:remaining]
- assert_equal 0, usage_info[:reset_time]
- assert_equal :noop, usage_info[:tier]
- end
-end