From 6441a2db4c35681431450c1f9d48431ce67d5d8d Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Tue, 26 May 2026 18:44:33 -0400 Subject: [PATCH] feat: service class unit tests for stats and timeline models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds model specs for CableStats, CableTimeline, CacheStats, CacheSizeStats, CacheTimeline, and QueueStats — completes the v0.8.0 test coverage milestone. 315 examples, 0 failures, 99.36% line coverage. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + ROADMAP.md | 7 +- .../solid_stack_web/cable_stats_spec.rb | 59 ++++++++++++ .../solid_stack_web/cable_timeline_spec.rb | 49 ++++++++++ .../solid_stack_web/cache_size_stats_spec.rb | 52 +++++++++++ .../solid_stack_web/cache_stats_spec.rb | 38 ++++++++ .../solid_stack_web/cache_timeline_spec.rb | 68 ++++++++++++++ .../solid_stack_web/queue_stats_spec.rb | 91 +++++++++++++++++++ 8 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 spec/models/solid_stack_web/cable_stats_spec.rb create mode 100644 spec/models/solid_stack_web/cable_timeline_spec.rb create mode 100644 spec/models/solid_stack_web/cache_size_stats_spec.rb create mode 100644 spec/models/solid_stack_web/cache_stats_spec.rb create mode 100644 spec/models/solid_stack_web/cache_timeline_spec.rb create mode 100644 spec/models/solid_stack_web/queue_stats_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2132975..6d26353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Service class unit tests — model specs for `CableStats`, `CableTimeline`, `CacheStats`, `CacheSizeStats`, `CacheTimeline`, and `QueueStats`; covers zero/empty states, correct aggregation, time-window filtering, and the `slow_job_threshold` conditional - Pagination boundary tests — `describe "pagination"` blocks added to jobs, failed jobs, cache entries, history, and cable message list specs; covers pagination controls appearing at 26+ records, page 2 returning 200, and out-of-range pages returning 200 without error - `spec/support/request_helpers.rb` — shared `engine_root` let for all request specs; `rails_helper.rb` auto-loads `spec/support/**/*.rb` diff --git a/ROADMAP.md b/ROADMAP.md index dab2d6c..acb8b82 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,12 +6,9 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das --- -## v0.8.0 — Test Coverage +## v0.8.0 — Test Coverage ✓ -> _Make the test suite match the surface area of the engine._ - -### Remaining -- Service class unit tests — `CableStats`, `CacheStats`, `QueueStats`, `CableTimeline`, `CacheSizeStats`, `CacheTimeline` have no specs; `AlertWebhook`, `QueueDepthSparkline`, `ThroughputSparkline` are already covered +> All items complete — 315 examples, 99.36% line coverage. --- diff --git a/spec/models/solid_stack_web/cable_stats_spec.rb b/spec/models/solid_stack_web/cable_stats_spec.rb new file mode 100644 index 0000000..25d0736 --- /dev/null +++ b/spec/models/solid_stack_web/cable_stats_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb::CableStats do + def broadcast(channel, payload = "msg") + SolidCable::Message.broadcast(channel, payload) + end + + describe "#to_h" do + it "returns zeros and nil when there are no messages" do + stats = described_class.new.to_h + expect(stats[:messages]).to eq(0) + expect(stats[:channels]).to eq(0) + expect(stats[:messages_per_hour]).to eq(0) + expect(stats[:oldest_message]).to be_nil + expect(stats[:top_channels]).to be_empty + end + + it "counts total messages" do + broadcast("chat", "hello") + broadcast("chat", "world") + expect(described_class.new.to_h[:messages]).to eq(2) + end + + it "counts distinct channels" do + broadcast("chat", "hi") + broadcast("sports", "goal") + broadcast("sports", "offside") + expect(described_class.new.to_h[:channels]).to eq(2) + end + + it "counts messages sent in the last hour" do + broadcast("chat", "recent") + old = SolidCable::Message.last + old.update_columns(created_at: 2.hours.ago) + broadcast("chat", "new") + expect(described_class.new.to_h[:messages_per_hour]).to eq(1) + end + + it "returns the oldest message timestamp" do + travel_to 10.minutes.ago do + broadcast("chat", "first") + end + broadcast("chat", "second") + oldest = SolidCable::Message.minimum(:created_at) + expect(described_class.new.to_h[:oldest_message]).to be_within(1.second).of(oldest) + end + + it "returns the top 3 channels by message volume" do + 3.times { broadcast("alpha", "m") } + 2.times { broadcast("beta", "m") } + 1.times { broadcast("gamma", "m") } + broadcast("delta", "m") + + top = described_class.new.to_h[:top_channels] + expect(top.keys.first).to eq("alpha") + expect(top.size).to be <= 3 + end + end +end diff --git a/spec/models/solid_stack_web/cable_timeline_spec.rb b/spec/models/solid_stack_web/cable_timeline_spec.rb new file mode 100644 index 0000000..49b61ae --- /dev/null +++ b/spec/models/solid_stack_web/cable_timeline_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb::CableTimeline do + def broadcast(channel, payload = "msg", created_at: Time.current) + SolidCable::Message.broadcast(channel, payload) + SolidCable::Message.order(created_at: :desc).first.tap do |m| + m.update_columns(created_at: created_at) + end + end + + describe "#message_buckets" do + it "returns 24 buckets" do + expect(described_class.new.message_buckets.size).to eq(24) + end + + it "returns all zeros when there are no messages" do + expect(described_class.new.message_buckets).to all(eq(0)) + end + + it "counts messages in the correct hour bucket" do + broadcast("chat", "hi", created_at: 1.hour.ago) + expect(described_class.new.message_buckets.sum).to eq(1) + end + + it "excludes messages older than 24 hours" do + broadcast("chat", "old", created_at: 25.hours.ago) + expect(described_class.new.message_buckets).to all(eq(0)) + end + + it "places messages in separate buckets based on hour" do + broadcast("chat", "early", created_at: 5.hours.ago) + broadcast("chat", "recent", created_at: 1.hour.ago) + buckets = described_class.new.message_buckets + expect(buckets.count { |b| b > 0 }).to eq(2) + end + end + + describe "#message_max" do + it "returns 0 when there are no messages" do + expect(described_class.new.message_max).to eq(0) + end + + it "returns the highest bucket count" do + 2.times { broadcast("chat", "m", created_at: 1.hour.ago) } + broadcast("sports", "g", created_at: 5.hours.ago) + expect(described_class.new.message_max).to eq(2) + end + end +end diff --git a/spec/models/solid_stack_web/cache_size_stats_spec.rb b/spec/models/solid_stack_web/cache_size_stats_spec.rb new file mode 100644 index 0000000..705f2c3 --- /dev/null +++ b/spec/models/solid_stack_web/cache_size_stats_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb::CacheSizeStats do + def create_entry(key:, value:) + SolidCache::Entry.write(key, value) + end + + describe "#total" do + it "returns 0 when the cache is empty" do + expect(described_class.new.total).to eq(0) + end + + it "returns the count of all entries" do + create_entry(key: "a", value: "x") + create_entry(key: "b", value: "y") + expect(described_class.new.total).to eq(2) + end + end + + describe "#top_entries" do + it "returns entries ordered by byte_size descending" do + create_entry(key: "small", value: "x") + create_entry(key: "large", value: "x" * 500) + keys = described_class.new.top_entries.map(&:key) + expect(keys.first).to eq("large") + end + + it "returns at most 10 entries" do + 15.times { |i| create_entry(key: "key#{i}", value: "v") } + expect(described_class.new.top_entries.size).to eq(10) + end + end + + describe "#buckets" do + it "returns one bucket per size range" do + expect(described_class.new.buckets.size).to eq(SolidStackWeb::CacheSizeStats::BUCKETS.size) + end + + it "counts entries in the correct size bucket" do + create_entry(key: "tiny", value: "x") + buckets = described_class.new.buckets + small_bucket = buckets.find { |b| b[:label] == "< 1 KB" } + expect(small_bucket[:count]).to eq(1) + end + + it "returns zero counts when the cache is empty" do + described_class.new.buckets.each do |b| + expect(b[:count]).to eq(0) + end + end + end +end diff --git a/spec/models/solid_stack_web/cache_stats_spec.rb b/spec/models/solid_stack_web/cache_stats_spec.rb new file mode 100644 index 0000000..826b64b --- /dev/null +++ b/spec/models/solid_stack_web/cache_stats_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb::CacheStats do + def create_entry(key: "k", value: "v") + SolidCache::Entry.write(key, value) + end + + describe "#to_h" do + it "returns zeros and nil when the cache is empty" do + stats = described_class.new.to_h + expect(stats[:entries]).to eq(0) + expect(stats[:byte_size]).to eq(0) + expect(stats[:oldest_entry]).to be_nil + end + + it "counts entries" do + create_entry(key: "a", value: "x") + create_entry(key: "b", value: "y") + expect(described_class.new.to_h[:entries]).to eq(2) + end + + it "sums byte_size across all entries" do + create_entry(key: "small", value: "x") + create_entry(key: "large", value: "x" * 100) + total = SolidCache::Entry.sum(:byte_size) + expect(described_class.new.to_h[:byte_size]).to eq(total) + end + + it "returns the oldest entry timestamp" do + travel_to 10.minutes.ago do + create_entry(key: "old", value: "o") + end + create_entry(key: "new", value: "n") + oldest = SolidCache::Entry.minimum(:created_at) + expect(described_class.new.to_h[:oldest_entry]).to be_within(1.second).of(oldest) + end + end +end diff --git a/spec/models/solid_stack_web/cache_timeline_spec.rb b/spec/models/solid_stack_web/cache_timeline_spec.rb new file mode 100644 index 0000000..013c33c --- /dev/null +++ b/spec/models/solid_stack_web/cache_timeline_spec.rb @@ -0,0 +1,68 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb::CacheTimeline do + def create_entry(key:, value:, created_at: Time.current) + SolidCache::Entry.write(key, value) + SolidCache::Entry.order(created_at: :desc).first.tap do |e| + e.update_columns(created_at: created_at) + end + end + + describe "#entry_buckets" do + it "returns 24 buckets" do + expect(described_class.new.entry_buckets.size).to eq(24) + end + + it "returns all zeros when the cache is empty" do + expect(described_class.new.entry_buckets).to all(eq(0)) + end + + it "counts entries created within the 24-hour window" do + create_entry(key: "recent", value: "v", created_at: 1.hour.ago) + expect(described_class.new.entry_buckets.sum).to eq(1) + end + + it "excludes entries older than 24 hours" do + create_entry(key: "old", value: "v", created_at: 25.hours.ago) + expect(described_class.new.entry_buckets).to all(eq(0)) + end + end + + describe "#byte_buckets" do + it "returns 24 buckets" do + expect(described_class.new.byte_buckets.size).to eq(24) + end + + it "returns all zeros when the cache is empty" do + expect(described_class.new.byte_buckets).to all(eq(0)) + end + + it "sums byte_size for entries in the window" do + create_entry(key: "data", value: "hello", created_at: 1.hour.ago) + expect(described_class.new.byte_buckets.sum).to be > 0 + end + end + + describe "#entry_max" do + it "returns 0 when the cache is empty" do + expect(described_class.new.entry_max).to eq(0) + end + + it "returns the highest bucket count" do + 2.times { |i| create_entry(key: "k#{i}", value: "v", created_at: 1.hour.ago) } + create_entry(key: "k2", value: "v", created_at: 5.hours.ago) + expect(described_class.new.entry_max).to eq(2) + end + end + + describe "#byte_max" do + it "returns 0 when the cache is empty" do + expect(described_class.new.byte_max).to eq(0) + end + + it "returns the highest byte sum across buckets" do + create_entry(key: "big", value: "x" * 100, created_at: 1.hour.ago) + expect(described_class.new.byte_max).to be > 0 + end + end +end diff --git a/spec/models/solid_stack_web/queue_stats_spec.rb b/spec/models/solid_stack_web/queue_stats_spec.rb new file mode 100644 index 0000000..c71fc15 --- /dev/null +++ b/spec/models/solid_stack_web/queue_stats_spec.rb @@ -0,0 +1,91 @@ +require "rails_helper" + +RSpec.describe SolidStackWeb::QueueStats do + def create_ready(queue_name: "default") + SolidQueue::Job.create!(class_name: "MyJob", queue_name:, priority: 0) + end + + def create_failed + SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution) + job = SolidQueue::Job.create!(class_name: "FailingJob", queue_name: "default", priority: 0) + SolidQueue::FailedExecution.create!( + job: job, + error: { exception_class: "RuntimeError", message: "boom", backtrace: [] } + ) + SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution) + end + + def create_process(heartbeat_at: Time.current) + SolidQueue::Process.create!( + kind: "Worker", name: "worker-#{SecureRandom.hex(4)}", pid: rand(99_999), + hostname: "test", last_heartbeat_at: heartbeat_at + ) + end + + def create_finished(finished_at: Time.current, duration: 10) + SolidQueue::Job.create!( + class_name: "FinishedJob", queue_name: "default", priority: 0, + created_at: finished_at - duration.seconds, + finished_at: finished_at + ) + end + + after { SolidStackWeb.slow_job_threshold = nil } + + describe "#to_h" do + it "returns zero counts when the queue is empty" do + stats = described_class.new.to_h + expect(stats[:ready]).to eq(0) + expect(stats[:scheduled]).to eq(0) + expect(stats[:claimed]).to eq(0) + expect(stats[:blocked]).to eq(0) + expect(stats[:failed]).to eq(0) + end + + it "counts ready executions" do + 2.times { create_ready } + expect(described_class.new.to_h[:ready]).to eq(2) + end + + it "counts failed executions" do + create_failed + expect(described_class.new.to_h[:failed]).to eq(1) + end + + it "counts jobs finished in the last hour" do + create_finished(finished_at: 30.minutes.ago) + create_finished(finished_at: 2.hours.ago) + expect(described_class.new.to_h[:done_1h]).to eq(1) + end + + it "counts jobs finished in the last 24 hours" do + create_finished(finished_at: 1.hour.ago) + create_finished(finished_at: 23.hours.ago) + create_finished(finished_at: 25.hours.ago) + expect(described_class.new.to_h[:done_24h]).to eq(2) + end + + it "counts healthy processes (heartbeat within last 5 minutes)" do + create_process(heartbeat_at: 2.minutes.ago) + create_process(heartbeat_at: 10.minutes.ago) + expect(described_class.new.to_h[:processes_healthy]).to eq(1) + end + + it "counts stale processes (heartbeat older than 5 minutes)" do + create_process(heartbeat_at: 2.minutes.ago) + create_process(heartbeat_at: 10.minutes.ago) + expect(described_class.new.to_h[:processes_stale]).to eq(1) + end + + it "omits slow_jobs key when no threshold is configured" do + expect(described_class.new.to_h).not_to have_key(:slow_jobs) + end + + it "counts slow jobs when threshold is configured" do + SolidStackWeb.slow_job_threshold = 30 + create_finished(duration: 60) + create_finished(duration: 10) + expect(described_class.new.to_h[:slow_jobs]).to eq(1) + end + end +end