require "rails_helper" RSpec.describe MatchmakingService do # The service uses the shared RedisConnection (test env points at db 15). def redis RedisConnection.with { |r| return r } end # Unique user ids across the whole spec run (Action Cable test-adapter # broadcasts persist between examples; example-group instance variables # are reset per example, so a global counter is used). def uid(base) $matchmaking_spec_uid = ($matchmaking_spec_uid || 0) + 1 $matchmaking_spec_uid * 1000 + base end def broadcasts_for(user_id) channel = MatchmakingChannel.broadcasting_for(user_id) ActionCable.server.pubsub.broadcasts(channel) end def broadcast_payloads(user_id) broadcasts_for(user_id).map { |m| JSON.parse(m, symbolize_names: true) } end before { redis.flushdb } describe ".enqueue" do it "does not match players whose ratings are outside the tolerance" do a, b = uid(1), uid(2) described_class.enqueue(a, rating: 1000) described_class.enqueue(b, rating: 5000) expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to contain_exactly(a.to_s, b.to_s) expect(broadcasts_for(a)).to be_empty expect(broadcasts_for(b)).to be_empty end it "matches two compatible players immediately, nearest rating first" do a, b, c = uid(1), uid(2), uid(3) described_class.enqueue(a, rating: 1000) described_class.enqueue(b, rating: 1400) # outside base range (100) described_class.enqueue(c, rating: 1050) # nearest to a match = broadcast_payloads(a).first expect(match[:type]).to eq("matched") expect(match[:players]).to contain_exactly(a, c) expect(match[:match_id]).to be_present expect(match[:game_token]).to be_present expect(match[:game_server]).to eq(Rails.application.config.x.matchmaking.game_server_url) expect(broadcasts_for(c)).not_to be_empty # b stays in the queue expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to eq([ b.to_s ]) end it "restarts the waiting session for a user already in the queue" do a = uid(1) described_class.enqueue(a, rating: 1000) described_class.enqueue(a, rating: 1200) expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to eq([ a.to_s ]) expect(redis.zscore(described_class::QUEUE_KEY, a.to_s)).to eq(1200) end it "delivers the match exactly once (broadcast consumes the pointer)" do a, b = uid(1), uid(2) described_class.enqueue(a, rating: 1000) described_class.enqueue(b, rating: 1000) expect(broadcasts_for(a).size).to eq(1) expect(described_class.claim(a)).to be_nil end end describe ".sweep" do it "widens the tolerance over waiting time and pairs players" do a, b = uid(1), uid(2) described_class.enqueue(a, rating: 1000) described_class.enqueue(b, rating: 1400) # backdate b's enqueue time (in ms, as the script stores it) so its # tolerance has widened enough redis.hset(described_class::SINCE_KEY, b.to_s, (10.seconds.ago.to_f * 1000).to_i) described_class.sweep expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty expect(broadcasts_for(a)).not_to be_empty expect(broadcasts_for(b)).not_to be_empty end it "expires players who waited longer than the queue TTL" do a = uid(1) described_class.enqueue(a, rating: 1000) redis.hset(described_class::SINCE_KEY, a.to_s, ((described_class.config.queue_ttl + 5).seconds.ago.to_f * 1000).to_i) described_class.sweep expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty expect(broadcast_payloads(a).first[:type]).to eq("timeout") end it "requeues players stuck in the pending state without a match" do a, b = uid(1), uid(2) described_class.enqueue(a, rating: 1000) described_class.enqueue(b, rating: 1000) # simulate a crash between pairing and finalize: back into pending # (scores are pairing timestamps in milliseconds). The pairing step # already removed the players from the queue; restore their ratings as # they would exist before finalize deletes them. redis.hset(described_class::RATING_KEY, a.to_s, 1000, b.to_s, 1000) past = (described_class.config.pending_ttl + 5).seconds.ago.to_f * 1000 redis.zadd(described_class::PENDING_KEY, past.to_i, a.to_s) redis.zadd(described_class::PENDING_KEY, past.to_i, b.to_s) # pointers must be absent for the requeue to kick in redis.del("#{described_class::USER_KEY_PREFIX}#{a}", "#{described_class::USER_KEY_PREFIX}#{b}") described_class.sweep expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to contain_exactly(a.to_s, b.to_s) end it "does not requeue pending players whose match was finalized" do a, b = uid(1), uid(2) described_class.enqueue(a, rating: 1000) described_class.enqueue(b, rating: 1000) # emulate a leftover stale pending entry for a user with a live pointer past = (described_class.config.pending_ttl + 5).seconds.ago.to_f * 1000 redis.zadd(described_class::PENDING_KEY, past.to_i, a.to_s) described_class.sweep expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty expect(broadcasts_for(a)).not_to be_empty end end describe ".cancel" do it "removes the player from the queue" do a = uid(1) described_class.enqueue(a, rating: 1000) described_class.cancel(a) expect(redis.zrange(described_class::QUEUE_KEY, 0, -1)).to be_empty expect(redis.hget(described_class::SINCE_KEY, a.to_s)).to be_nil end end describe ".claim" do it "returns the match payload exactly once" do a = uid(1) payload = { type: "matched", match_id: "m-1", players: [ a, a + 1 ] }.to_json redis.set("#{described_class::USER_KEY_PREFIX}#{a}", payload) first = described_class.claim(a) expect(first[:type]).to eq("matched") expect(described_class.claim(a)).to be_nil end end end