diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..578a67d --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--profile 25 diff --git a/spec/client_spec.rb b/spec/client_spec.rb index b120f1e..6190e6a 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -6,6 +6,10 @@ require 'faraday' describe StreamChat::Client do + # Block-form retry helper. Kept under the original `loop_times` name to + # preserve backward compatibility with existing call sites. New tests + # should prefer the `eventually` RSpec matcher defined above, which reads + # as an eventual-consistency contract rather than a retry loop. def loop_times(times) loop do begin @@ -20,16 +24,44 @@ def loop_times(times) end end + # Hard-delete fellowship leftovers from prior CI runs whose after(:all) + # didn't fire. `delete_users` is idempotent so this is safe to re-run. + # Without it the shared test app accumulates Frodos / Sams / Gandalfs / + # Legolases forever and other tests that query users by name match sediment. + def cleanup_fellowship_leftovers!(client) + names = ['Frodo Baggins', 'Samwise Gamgee', 'Gandalf the Grey', 'Legolas'] + leftover = client.query_users({ 'name' => { '$in' => names } }) + leftover_ids = leftover['users'].map { |u| u['id'] } + return if leftover_ids.empty? + + client.delete_users(leftover_ids, + user: StreamChat::HARD_DELETE, + messages: StreamChat::HARD_DELETE) + rescue StandardError + # Best-effort: a failed cleanup must not block tests; the per-run race + # tag below isolates concurrent runs that race past this point. + end + before(:all) do @client = StreamChat::Client.from_env + cleanup_fellowship_leftovers!(@client) + end + before(:all) do @created_users = [] + # Per-run race markers so concurrent CI runs (and the seconds-window between + # the active cleanup above and the upserts below) don't collide on + # race=Hobbit / Elf / Istari lookups. + @hobbit_race = "Hobbit-#{SecureRandom.alphanumeric(8)}" + @elf_race = "Elf-#{SecureRandom.alphanumeric(8)}" + @istari_race = "Istari-#{SecureRandom.alphanumeric(8)}" + @fellowship_of_the_ring = [ - { id: SecureRandom.uuid, name: 'Frodo Baggins', race: 'Hobbit', age: 50 }, - { id: SecureRandom.uuid, name: 'Samwise Gamgee', race: 'Hobbit', age: 38 }, - { id: SecureRandom.uuid, name: 'Gandalf the Grey', race: 'Istari' }, - { id: SecureRandom.uuid, name: 'Legolas', race: 'Elf', age: 500 } + { id: SecureRandom.uuid, name: 'Frodo Baggins', race: @hobbit_race, age: 50 }, + { id: SecureRandom.uuid, name: 'Samwise Gamgee', race: @hobbit_race, age: 38 }, + { id: SecureRandom.uuid, name: 'Gandalf the Grey', race: @istari_race }, + { id: SecureRandom.uuid, name: 'Legolas', race: @elf_race, age: 500 } ] @legolas = @fellowship_of_the_ring[3][:id] @@ -588,7 +620,7 @@ def loop_times(times) end it 'queries users' do - response = @client.query_users({ 'race' => { '$eq' => 'Hobbit' } }, sort: { 'age' => -1 }) + response = @client.query_users({ 'race' => { '$eq' => @hobbit_race } }, sort: { 'age' => -1 }) expect(response['users'].length).to eq 2 expect([50, 38]).to eq(response['users'].map { |u| u['age'] }) end @@ -1025,20 +1057,22 @@ def loop_times(times) expect(cmd['description']).to eq 'I am testing' end + # Each follow-up step uses the `eventually` matcher to absorb the + # backend's eventual-consistency lag between Create / Get / Update / + # Delete — on a cold shared CI app the next call regularly hits + # "the command ... does not exist" until the write propagates. it 'get that command' do - cmd = @client.get_command(@cmd) - expect(cmd['name']).to eq @cmd - expect(cmd['description']).to eq 'I am testing' + expect { @client.get_command(@cmd) } + .to eventually(include('name' => @cmd, 'description' => 'I am testing')) end it 'update that command' do - cmd = @client.update_command(@cmd, { description: 'I tested' })['command'] - expect(cmd['name']).to eq @cmd - expect(cmd['description']).to eq 'I tested' + expect { @client.update_command(@cmd, { description: 'I tested' })['command'] } + .to eventually(include('name' => @cmd, 'description' => 'I tested')) end it 'delete that command' do - @client.delete_command(@cmd) + expect { @client.delete_command(@cmd) }.to eventually(be_truthy) end it 'list commands' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 86ec841..5842bd5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,3 +5,7 @@ formatter SimpleCov::Formatter::Console add_filter '/spec/' end + +# Autoload everything under spec/support — custom RSpec matchers, shared +# contexts, fixtures, etc. +Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } diff --git a/spec/support/matchers/eventually.rb b/spec/support/matchers/eventually.rb new file mode 100644 index 0000000..0f024d9 --- /dev/null +++ b/spec/support/matchers/eventually.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Block matcher that retries `expect { ... }` against an inner matcher until +# it passes or the budget runs out. Designed for the eventually-consistent +# bits of the shared-CI backend (e.g. create_command -> get_command races). +# +# expect { client.get_command(name) }.to eventually(include('name' => name)) +# +# Tweak the budget with chainable `.with_retries(n)` / `.every(seconds)`. +RSpec::Matchers.define :eventually do |inner_matcher| + supports_block_expectations + + match do |block| + @retries = (defined?(@retries_value) && @retries_value) || 3 + @interval = (defined?(@interval_value) && @interval_value) || 1 + @last_actual = nil + @last_error = nil + attempts = 0 + loop do + begin + @last_actual = block.call + break true if inner_matcher.matches?(@last_actual) + rescue StandardError, RSpec::Expectations::ExpectationNotMetError => e + @last_error = e + end + attempts += 1 + break false if attempts >= @retries + + sleep(@interval) + end + end + + chain :with_retries do |n| + @retries_value = n + end + + chain :every do |seconds| + @interval_value = seconds + end + + failure_message do |_block| + msg = "expected block to eventually #{inner_matcher.description}" + msg += "; last value: #{@last_actual.inspect}" unless @last_actual.nil? + msg += "; last error: #{@last_error.message}" if @last_error + msg + end +end diff --git a/spec/support/timing.rb b/spec/support/timing.rb new file mode 100644 index 0000000..59573a6 --- /dev/null +++ b/spec/support/timing.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Per-test wall-clock timing emitted to STDOUT so CI logs surface which +# example is in flight when a runner stalls. The default `--format +# documentation` reporter prints test names but not timing; `--profile` +# only summarises at the end (no help when the run hangs before reaching +# the summary). This hook fills the gap: every example logs +# `[timing] start ...` on enter and `[timing] N.NN s ...` on exit with +# its full description path, so an unattended runner stuck mid-test +# leaves a clear breadcrumb. +RSpec.configure do |config| + config.before(:each) do |example| + example.metadata[:timing_started_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + warn "[timing] start #{example.full_description}" + end + + config.after(:each) do |example| + started_at = example.metadata[:timing_started_at] + next unless started_at + + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + status = example.exception ? 'fail' : 'pass' + warn format( + '[timing] %.2f s %s %s', + elapsed: elapsed, + status: status, + description: example.full_description + ) + end +end