diff --git a/.ruby-version b/.ruby-version index f13c6f45..e391e180 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.3.5 +ruby-3.3.6 diff --git a/.tool-versions b/.tool-versions index 1dd19980..5aa8e0c3 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.3.5 +ruby 3.3.6 diff --git a/Gemfile b/Gemfile index 95bc2528..53375837 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -ruby "3.3.5" +ruby "3.3.6" gem "aasm", "~> 5.5" gem "amazing_print" diff --git a/Gemfile.lock b/Gemfile.lock index c91b5f97..6736c7dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -438,7 +438,7 @@ DEPENDENCIES yaml-lint (~> 0.1.2) RUBY VERSION - ruby 3.3.5p100 + ruby 3.3.6p108 BUNDLED WITH 2.5.16 diff --git a/app/models/feed.rb b/app/models/feed.rb index e00e955b..03334302 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -1,7 +1,7 @@ class Feed < ApplicationRecord include AASM - MAX_LIMIT_LIMIT = 100 + MAX_IMPORT_LIMIT = 100 IMPORT_LIMIT_RANGE = 0..(86400 * 7) NAME_LENGTH_RANGE = 3..80 MAX_URL_LENGTH = 4096 @@ -14,7 +14,7 @@ class Feed < ApplicationRecord validates :name, presence: true, length: NAME_LENGTH_RANGE, format: /\A[\w\-]+\z/ normalizes :name, with: ->(name) { name.to_s.strip.downcase } - validates :import_limit, numericality: {less_than_or_equal_to: MAX_LIMIT_LIMIT} + validates :import_limit, numericality: {less_than_or_equal_to: MAX_IMPORT_LIMIT} validates :refresh_interval, presence: true, numericality: {greater_than_or_equal_to: 0} validates :loader, :normalizer, :processor, presence: true, format: /\A\w+\z/ validates :url, length: {maximum: MAX_URL_LENGTH}, allow_nil: true @@ -46,17 +46,33 @@ class Feed < ApplicationRecord end end + scope :ordered_by, ->(attribute, direction) { order(sanitize_sql_for_order("#{attribute} #{direction} NULLS LAST")) } + + scope :stale, lambda { + where(refresh_interval: 0) + .or(where(refreshed_at: nil)) + .or(where("age(now(), refreshed_at) > make_interval(secs => refresh_interval)")) + } + def configurable? updated_at.blank? || configured_at.blank? || updated_at.change(usec: 0) <= configured_at.change(usec: 0) end + # @return [true, false] true when the feed needs a refresh + def stale? + refresh_interval.zero? || refreshed_at.blank? || time_to_refresh? + end + def reference [self.class.name.underscore, id, name].compact_blank.join("-") end def ensure_supported - return true if loader_class && processor_class && normalizer_class - raise FeedConfigurationError + if loader_class && processor_class && normalizer_class + true + else + raise FeedConfigurationError + end end def service_classes @@ -69,8 +85,6 @@ def service_classes def loader_class ClassResolver.new(loader, suffix: "loader").resolve - rescue NameError - nil end def loader_instance @@ -79,8 +93,6 @@ def loader_instance def processor_class ClassResolver.new(processor, suffix: "processor").resolve - rescue NameError - nil end def processor_instance @@ -89,8 +101,6 @@ def processor_instance def normalizer_class ClassResolver.new(normalizer, suffix: "normalizer").resolve - rescue NameError - nil end private @@ -98,4 +108,12 @@ def normalizer_class def options_must_be_hash errors.add(:options, :not_a_hash, message: "must be a hash") unless options.is_a?(Hash) end + + def time_to_refresh? + seconds_since_last_refresh > refresh_interval + end + + def seconds_since_last_refresh + (Time.now.utc.to_i - refreshed_at.to_i).abs + end end diff --git a/app/services/class_resolver.rb b/app/services/class_resolver.rb index 7f03948f..2edb0187 100644 --- a/app/services/class_resolver.rb +++ b/app/services/class_resolver.rb @@ -15,5 +15,7 @@ def initialize(class_name, suffix: nil) # @raise [NameError] if the target class is missing def resolve [class_name, suffix].join("_").classify.constantize + rescue NameError + nil end end diff --git a/app/services/feed_processor.rb b/app/services/feed_processor.rb index 9bb1bcb1..760e7e27 100644 --- a/app/services/feed_processor.rb +++ b/app/services/feed_processor.rb @@ -10,7 +10,6 @@ def initialize(feeds:) @feeds = feeds end - # TBD: This should receive "stale enabled" feeds; test Feed model scopes def perform feeds.each do |feed| Importer.new(feed).import diff --git a/spec/models/feed_spec.rb b/spec/models/feed_spec.rb index ad18f7a9..81eb57e4 100644 --- a/spec/models/feed_spec.rb +++ b/spec/models/feed_spec.rb @@ -1,4 +1,6 @@ RSpec.describe Feed do + before { freeze_time } + describe "relations" do subject(:feed) { build(:feed) } @@ -42,7 +44,7 @@ describe "#import_limit" do it "validates numericality" do - expect(feed).to validate_numericality_of(:import_limit).is_less_than_or_equal_to(Feed::MAX_LIMIT_LIMIT) + expect(feed).to validate_numericality_of(:import_limit).is_less_than_or_equal_to(Feed::MAX_IMPORT_LIMIT) end end @@ -128,6 +130,58 @@ end end + describe ".ordered_by" do + it "orders records by the specified attribute and direction" do + create(:feed, name: "bbb", refreshed_at: 2.days.ago) + create(:feed, name: "aaa", refreshed_at: 1.day.ago) + feeds = described_class.ordered_by("name", "ASC") + + expect(feeds.map(&:name)).to eq(%w[aaa bbb]) + end + + it "puts null values last when descending" do + create(:feed, name: "bbb", refreshed_at: nil) + create(:feed, name: "aaa", refreshed_at: 1.day.ago) + feeds = described_class.ordered_by("refreshed_at", "DESC") + + expect(feeds.map(&:name)).to eq(%w[aaa bbb]) + end + + it "puts null values last when ascending" do + create(:feed, name: "bbb", refreshed_at: nil) + create(:feed, name: "aaa", refreshed_at: 1.day.ago) + feeds = described_class.ordered_by("refreshed_at", "ASC") + + expect(feeds.map(&:name)).to eq(%w[aaa bbb]) + end + end + + describe ".stale" do + it "includes feeds with zero refresh_interval" do + feed = create(:feed, refresh_interval: 0, refreshed_at: 1.minute.ago) + + expect(described_class.stale).to eq([feed]) + end + + it "includes feeds with nil refreshed_at" do + feed = create(:feed, refresh_interval: 1.hour.to_i, refreshed_at: nil) + + expect(described_class.stale).to eq([feed]) + end + + it "includes feeds that are past their refresh interval" do + feed = create(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 2.hours.ago) + + expect(described_class.stale).to eq([feed]) + end + + it "excludes fresh feeds" do + create(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 30.minutes.ago) + + expect(described_class.stale).to be_empty + end + end + describe "#configurable?" do let(:arbitrary_time) { Time.current } @@ -152,6 +206,24 @@ end end + describe "#stale?" do + context "when refresh_interval is zero" do + it { expect(build(:feed, refresh_interval: 0)).to be_stale } + end + + context "when refreshed_at is nil" do + it { expect(build(:feed, refresh_interval: 1.hour.to_i, refreshed_at: nil)).to be_stale } + end + + context "when past refresh interval" do + it { expect(build(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 2.hours.ago)).to be_stale } + end + + context "when within refresh interval" do + it { expect(build(:feed, refresh_interval: 1.hour.to_i, refreshed_at: 30.minutes.ago)).not_to be_stale } + end + end + describe "#reference" do it "returns expected value" do actual = build(:feed, id: 1, name: "sample").reference diff --git a/spec/services/class_resolver_spec.rb b/spec/services/class_resolver_spec.rb index de308be5..aa5ccd93 100644 --- a/spec/services/class_resolver_spec.rb +++ b/spec/services/class_resolver_spec.rb @@ -28,7 +28,7 @@ it "raises a NameError for missing class" do resolver = described_class.new("non_existent") - expect { resolver.resolve }.to raise_error(NameError) + expect(resolver.resolve).to be_nil end end end