# frozen_string_literal: true require_relative "helper" require "currency" require "provider" require "provider/adapters/adapter" describe Provider do let(:provider) { Provider.first } describe "schema" do let(:columns) { Provider.db.schema(:providers).map(&:first) } it "has publish_schedule, publish_cadence, and no publish_time or publish_days" do _(columns).must_include(:publish_schedule) _(columns).must_include(:publish_cadence) _(columns).wont_include(:publish_time) _(columns).wont_include(:publish_days) end it "seeds every provider with publish_schedule, publish_cadence, and valid cron" do require "fugit" dir = File.expand_path("../db/seeds/providers", __dir__) Dir["#{dir}/*.json"].each do |path| data = JSON.parse(File.read(path)) _(data).must_include("publish_schedule") _(data).must_include("publish_cadence") _(data).wont_include("publish_time") _(data).wont_include("publish_days") _([nil, "daily", "weekly", "monthly"]).must_include(data["publish_cadence"]) _(data["publish_cadence"].nil?).must_equal(data["publish_schedule"].nil?) next if data["publish_schedule"].nil? parsed = Fugit::Cron.parse(data["publish_schedule"]) _(parsed).wont_be_nil("#{File.basename(path)}: invalid cron #{data["publish_schedule"].inspect}") end end end describe "#adapter" do it "finds adapter by key" do stub_adapter = Class.new(Provider::Adapters::Adapter) Provider::Adapters.const_set(:STUB, stub_adapter) stub_provider = Provider.new do |p| p.key = "STUB" p.name = "Stub" end _(stub_provider.adapter).must_equal(Provider::Adapters::STUB) ensure Provider::Adapters.send(:remove_const, :STUB) if Provider::Adapters.const_defined?(:STUB) end it "resolves all seeded providers" do require "provider/adapters" Provider.all.each do |provider| _(Provider::Adapters.const_defined?(provider.key)).must_equal(true) end end end describe "#publishes_missed" do def build_provider(schedule, cadence: "daily") Provider.new do |p| p.key = "EXAMPLE" p.name = "Example" p.publish_schedule = schedule p.publish_cadence = cadence if schedule end end it "returns nil when publish_schedule is nil" do provider = build_provider(nil, cadence: nil) provider.stub(:end_date, "2026-04-01") do _(provider.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_be_nil end end it "returns nil when end_date is nil" do provider = build_provider("*/30 14-16 * * 1-5") provider.stub(:end_date, nil) do _(provider.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_be_nil end end it "raises ArgumentError when publish_cadence is unrecognised" do bad = build_provider("*/30 14-16 * * 1-5", cadence: "biweekly") bad.stub(:end_date, "2026-04-01") do _ { bad.publishes_missed(reference_date: Date.new(2026, 4, 20)) }.must_raise(ArgumentError) end end describe "with daily cadence, Mon-Fri publishing (ECB-style)" do let(:mon_fri) { build_provider("*/30 14-16 * * 1-5") } it "returns 0 when end_date is Friday and reference is the following Monday" do # Fri 2026-04-17 to Mon 2026-04-20 exclusive: only Sat 18 and Sun 19 sit between — neither is a publish day. mon_fri.stub(:end_date, "2026-04-17") do _(mon_fri.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_equal(0) end end it "counts weekdays missed between end_date and reference" do # Last update Mon 2026-04-13, reference Fri 2026-04-17 exclusive: Tue 14, Wed 15, Thu 16 = 3. mon_fri.stub(:end_date, "2026-04-13") do _(mon_fri.publishes_missed(reference_date: Date.new(2026, 4, 17))).must_equal(3) end end it "ignores weekends" do # Fri 2026-04-10 exclusive to Mon 2026-04-20 exclusive: Mon-Fri 13,14,15,16,17 = 5. mon_fri.stub(:end_date, "2026-04-10") do _(mon_fri.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_equal(5) end end end describe "with daily cadence, seven-day publishing" do let(:daily) { build_provider("*/30 14-16 * * *") } it "counts every day between end_date and reference" do # (2026-04-10, 2026-04-20) exclusive = 9 days. daily.stub(:end_date, "2026-04-10") do _(daily.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_equal(9) end end end describe "with daily cadence, Mondays-only cron" do let(:mondays) { build_provider("*/30 21-23 * * 1") } it "counts only Mondays between end_date and reference" do # Mon 2026-04-06 exclusive to Fri 2026-04-24 exclusive: Mondays are 13, 20 = 2. mondays.stub(:end_date, "2026-04-06") do _(mondays.publishes_missed(reference_date: Date.new(2026, 4, 24))).must_equal(2) end end end describe "with weekly cadence (FRED-style, Monday publishing covers prior ISO week)" do let(:fred) { build_provider("*/30 21-23 * * 1", cadence: "weekly") } it "returns 0 when end_date is in the week FRED's last batch covered" do # Today 2026-04-21 (Tue). Last Monday fire Apr 20 covers prior ISO week Apr 13-19. # end_date = 2026-04-19 → same ISO week bucket as Apr 13 → 0 missed. fred.stub(:end_date, "2026-04-19") do _(fred.publishes_missed(reference_date: Date.new(2026, 4, 21))).must_equal(0) end end it "returns 0 when end_date's ISO week matches expected coverage bucket" do # end_date = Saturday 2026-04-18 (same ISO week as Apr 13-19). Still 0 missed. fred.stub(:end_date, "2026-04-18") do _(fred.publishes_missed(reference_date: Date.new(2026, 4, 21))).must_equal(0) end end it "returns 1 when end_date is one ISO week behind expected" do # end_date in week Apr 6-12; expected = week Apr 13-19 → 1 missed. fred.stub(:end_date, "2026-04-12") do _(fred.publishes_missed(reference_date: Date.new(2026, 4, 21))).must_equal(1) end end it "returns 0 on Sunday before Monday batch arrives (expected is 2 weeks ago)" do # Today Sun 2026-04-19. Last Mon fire Apr 13 covers prior week Apr 6-12. # end_date = Apr 12 (covered) → 0. fred.stub(:end_date, "2026-04-12") do _(fred.publishes_missed(reference_date: Date.new(2026, 4, 19))).must_equal(0) end end it "reports 1 missed on Monday morning when prior week's batch did not arrive" do # Today Mon 2026-04-20 (FRED's first publish day of this ISO week). # end_date Sun 2026-04-12 = end of ISO week Apr 6-12; expected = ISO week Apr 13-19 → 1 missed. fred.stub(:end_date, "2026-04-12") do _(fred.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_equal(1) end end end describe "with monthly cadence (HKMA-style, day-of-month publishing covers prior month)" do let(:hkma) { build_provider("*/30 1-10 3-12 * *", cadence: "monthly") } it "returns 0 when current with March data and today is past April's window" do hkma.stub(:end_date, "2026-03-31") do _(hkma.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_equal(0) end end it "returns 0 when current with March data and today is before April's window" do hkma.stub(:end_date, "2026-03-31") do _(hkma.publishes_missed(reference_date: Date.new(2026, 4, 2))).must_equal(0) end end it "returns 0 when last business day is not last calendar day (March 30 edge)" do # Nov 30 2025 was a Sunday; last HKMA date was Nov 29. Should still count as Nov covered. hkma.stub(:end_date, "2025-11-29") do _(hkma.publishes_missed(reference_date: Date.new(2025, 12, 15))).must_equal(0) end end it "returns 1 when April batch did not arrive and today is past May's window" do hkma.stub(:end_date, "2026-03-31") do _(hkma.publishes_missed(reference_date: Date.new(2026, 5, 15))).must_equal(1) end end it "returns 2 when two monthly batches are missed" do hkma.stub(:end_date, "2026-01-31") do _(hkma.publishes_missed(reference_date: Date.new(2026, 4, 20))).must_equal(2) end end it "reports 1 missed on the first publish day of the window when prior month did not arrive" do # Today Apr 3 = first day of HKMA's publish window (DOM 3-12). # end_date Feb 28; expected = March → 1 missed. hkma.stub(:end_date, "2026-02-28") do _(hkma.publishes_missed(reference_date: Date.new(2026, 4, 3))).must_equal(1) end end end end describe "#backfill" do let(:provider) { Provider[key: "BCB"].dup } let(:fetched_params) { [] } let(:import_date) { Date.new(2099, 1, 1) } let(:adapter) do params = fetched_params d = import_date Class.new(Provider::Adapters::Adapter) do define_method(:fetch) do |after: nil, upto: nil| params << { after:, upto: } [{ date: d, base: "EUR", quote: "USD", rate: 1.1 }] end end end it "imports fetched records" do provider.stub(:adapter, adapter) do provider.backfill end record = Rate.where(provider: provider.key, date: import_date).first _(record.base).must_equal("EUR") _(record.quote).must_equal("USD") _(record.rate).must_equal(1.1) end it "upserts without duplicating" do provider.stub(:adapter, adapter) do provider.backfill provider.backfill(after: Date.today - 1) end _(Rate.where(provider: provider.key, date: import_date).count).must_equal(1) end it "excludes unrecognised currency codes" do bad_adapter = Class.new(Provider::Adapters::Adapter) do define_method(:fetch) do |**| [ { date: Date.new(2099, 1, 1), base: "EUR", quote: "USD", rate: 1.1 }, { date: Date.new(2099, 1, 1), base: "EUR", quote: "SDR", rate: 1.5 }, ] end end provider.stub(:adapter, bad_adapter) do provider.backfill end _(Rate.where(provider: provider.key, quote: "SDR").count).must_equal(0) end it "ingests XDR" do xdr_adapter = Class.new(Provider::Adapters::Adapter) do define_method(:fetch) do |**| [ { date: Date.new(2099, 1, 1), base: "EUR", quote: "USD", rate: 1.1 }, { date: Date.new(2099, 1, 1), base: "EUR", quote: "XDR", rate: 0.8 }, ] end end provider.stub(:adapter, xdr_adapter) do provider.backfill end _(Rate.where(provider: provider.key, quote: "XDR").count).must_equal(1) end it "purges cache when new rates are inserted" do cache_purged = false Cache.stub(:purge, -> { cache_purged = true }) do provider.stub(:adapter, adapter) do provider.backfill end end _(cache_purged).must_equal(true) end it "does not purge cache when no new rates are inserted" do provider.stub(:adapter, adapter) do provider.backfill end cache_purged = false Cache.stub(:purge, -> { cache_purged = true }) do provider.stub(:adapter, adapter) do provider.backfill(after: import_date - 1) end end _(cache_purged).must_equal(false) end it "skips when already up to date" do Rate.dataset.insert( date: Date.today, provider: provider.key, base: "EUR", quote: "USD", rate: 1.1, ) called = false boom = Class.new(Provider::Adapters::Adapter) do define_method(:fetch) do |**| called = true [] end end provider.stub(:adapter, boom) do provider.backfill end _(called).must_equal(false) end it "chunks when adapter has backfill_range" do since = Date.today - 90 Rate.dataset.insert( date: since, provider: provider.key, base: "EUR", quote: "USD", rate: 1.0, ) ranged_adapter = Class.new(adapter) do class << self def backfill_range = 30 end end provider.stub(:adapter, ranged_adapter) do provider.backfill end _(fetched_params.length).must_equal(4) _(fetched_params[0][:after]).must_equal(since) _(fetched_params[0][:upto]).must_equal(since + 29) _(fetched_params[-1][:upto]).must_be_nil end it "refreshes currencies and currency coverages" do Currency.dataset.delete CurrencyCoverage.dataset.delete provider.stub(:adapter, adapter) do provider.backfill end _(CurrencyCoverage.where(provider_key: provider.key).count).must_be(:>, 0) _(Currency.where(iso_code: "USD").count).must_equal(1) _(Currency.where(iso_code: "EUR").count).must_equal(1) end it "stores per-provider date ranges in coverages" do CurrencyCoverage.dataset.delete provider.stub(:adapter, adapter) do provider.backfill end coverage = CurrencyCoverage.where(provider_key: provider.key, iso_code: "USD").first _(coverage.start_date.to_s).must_equal(import_date.to_s) _(coverage.end_date.to_s).must_equal(import_date.to_s) end it "skips when api key is required but missing" do gated_adapter = Class.new(Provider::Adapters::Adapter) do define_method(:fetch) do |**| raise Provider::Adapters::Adapter::Unavailable, "no API key" end end provider.stub(:adapter, gated_adapter) do provider.backfill end _(Rate.where(provider: provider.key, date: import_date).count).must_equal(0) end end end