diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c49f774..41b1188 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,14 @@ jobs: strategy: matrix: ruby: - - '3.2.2' + - '2.6' + - '2.7' + - '3.0' + - '3.1' + - '3.2' + - '3.3' + - '3.4' + - '4.0' steps: - uses: actions/checkout@v3 diff --git a/spec/car_archive_spec.rb b/spec/car_archive_spec.rb new file mode 100644 index 0000000..0f58c39 --- /dev/null +++ b/spec/car_archive_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'base64' +require 'cbor' + +describe Skyfall::CarArchive do + it "should convert nested CBOR tagged values to CID links" do + tag = CBOR::Tagged.new(42, "\x00" + ("a" * 32)) + data = { "link" => tag } + + described_class.convert_data(data) + + data["link"]["$link"].should be_a(Skyfall::CID) + end + + it "should convert binary strings to bytes objects" do + bytes = "\x00\x01".b + data = { "payload" => bytes } + + described_class.convert_data(data) + + data["payload"]["$bytes"].should eq(Base64.encode64(bytes).chomp.gsub(/=+$/, "")) + end + + it "should raise when converting unexpected value types" do + expect { described_class.convert_data("bad") }.to raise_error(Skyfall::DecodeError) + end +end diff --git a/spec/cid_spec.rb b/spec/cid_spec.rb new file mode 100644 index 0000000..0989a66 --- /dev/null +++ b/spec/cid_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +describe Skyfall::CID do + it "should build from a CBOR tag" do + data = "\x00" + ("a" * 32) + tag = Struct.new(:value).new(data) + + cid = described_class.from_cbor_tag(tag) + + cid.data.should eq("a" * 32) + end + + it "should raise when CBOR tag is invalid" do + tag = Struct.new(:value).new("\x01bad") + + expect { described_class.from_cbor_tag(tag) }.to raise_error(Skyfall::DecodeError) + end + + it "should build from JSON string" do + cid = described_class.new("b" * 36) + + parsed = described_class.from_json(cid.to_s) + + parsed.should eq(cid) + end + + it "should raise when JSON CID has unexpected length" do + expect { described_class.from_json("bshort") }.to raise_error(Skyfall::DecodeError) + end + + it "should raise when JSON CID has invalid prefix" do + expect { described_class.from_json("z" + ("a" * 58)) }.to raise_error(Skyfall::DecodeError) + end + + it "should return a multibase string" do + cid = described_class.new("b" * 36) + + cid.to_s.should start_with("b") + end + + it "should inspect with CID wrapper" do + cid = described_class.new("b" * 36) + + cid.inspect.should include("CID(\"") + end +end diff --git a/spec/collection_spec.rb b/spec/collection_spec.rb new file mode 100644 index 0000000..21cc322 --- /dev/null +++ b/spec/collection_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +describe Skyfall::Collection do + it "should map known collection names to short codes" do + described_class.short_code(described_class::BSKY_POST).should eq(:bsky_post) + end + + it "should return :unknown for unknown collections" do + described_class.short_code("app.bsky.unknown").should eq(:unknown) + end + + it "should map short codes back to collection names" do + described_class.from_short_code(:bsky_like).should eq(described_class::BSKY_LIKE) + end + + it "should return nil for unknown short codes" do + described_class.from_short_code(:nonexistent).should be_nil + end +end diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb new file mode 100644 index 0000000..4362677 --- /dev/null +++ b/spec/errors_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +describe Skyfall::ReactorActiveError do + it "should include a helpful message" do + error = described_class.new + + error.message.should include("EventMachine reactor thread") + end +end + +describe Skyfall::SubscriptionError do + it "should expose error details" do + error = described_class.new("Boom", "Something happened") + + error.error_type.should eq("Boom") + error.error_message.should eq("Something happened") + error.message.should include("Boom") + end +end diff --git a/spec/firehose_message_spec.rb b/spec/firehose_message_spec.rb new file mode 100644 index 0000000..ec37077 --- /dev/null +++ b/spec/firehose_message_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'cbor' + +describe Skyfall::Firehose::Message do + def encode_message(type, data) + CBOR.encode(type) + CBOR.encode(data) + end + + it "should parse commit messages" do + type = { "op" => 1, "t" => "#commit" } + data = { + "seq" => 1, + "repo" => "did:example:repo", + "commit" => CBOR::Tagged.new(42, "\x00" + ("a" * 32)), + "blocks" => "car", + "ops" => [], + "time" => "2024-01-01T00:00:00Z" + } + + message = described_class.new(encode_message(type, data)) + + message.should be_a(Skyfall::Firehose::CommitMessage) + message.type.should eq(:commit) + message.repo.should eq("did:example:repo") + message.seq.should eq(1) + end + + it "should parse account messages" do + type = { "op" => 1, "t" => "#account" } + data = { + "seq" => 2, + "did" => "did:example:acct", + "time" => "2024-01-01T00:00:00Z", + "active" => true + } + + message = described_class.new(encode_message(type, data)) + + message.should be_a(Skyfall::Firehose::AccountMessage) + message.active?.should be(true) + end + + it "should parse info messages" do + type = { "op" => 1, "t" => "#info" } + data = { "name" => "OutdatedCursor", "message" => "Old" } + + message = described_class.new(encode_message(type, data)) + + message.should be_a(Skyfall::Firehose::InfoMessage) + message.to_s.should include("OutdatedCursor") + end + + it "should treat unknown messages as unknown" do + type = { "op" => 1, "t" => "#mystery" } + data = { "seq" => 3 } + + message = described_class.new(encode_message(type, data)) + + message.should be_a(Skyfall::Firehose::UnknownMessage) + message.unknown?.should be(true) + end + + it "should raise when error is present" do + type = { "op" => 1, "t" => "#commit" } + data = { "error" => "Boom", "message" => "Bad" } + + expect { described_class.new(encode_message(type, data)) }.to raise_error(Skyfall::SubscriptionError) + end + + it "should raise on invalid message format" do + expect { described_class.new(CBOR.encode({})) }.to raise_error(Skyfall::DecodeError) + end +end diff --git a/spec/firehose_spec.rb b/spec/firehose_spec.rb new file mode 100644 index 0000000..fac51b8 --- /dev/null +++ b/spec/firehose_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +describe Skyfall::Firehose do + it "should default to subscribe repos" do + firehose = described_class.new("example.com") + + firehose.send(:build_websocket_url).should eq("wss://example.com/xrpc/#{Skyfall::Firehose::SUBSCRIBE_REPOS}") + end + + it "should accept a cursor as the second argument" do + firehose = described_class.new("example.com", 12) + + firehose.cursor.should eq(12) + firehose.send(:build_websocket_url).should eq("wss://example.com/xrpc/#{Skyfall::Firehose::SUBSCRIBE_REPOS}?cursor=12") + end + + it "should accept named endpoints" do + firehose = described_class.new("example.com", :subscribe_labels) + + firehose.send(:build_websocket_url).should eq("wss://example.com/xrpc/#{Skyfall::Firehose::SUBSCRIBE_LABELS}") + end + + it "should reject unknown endpoints" do + expect { described_class.new("example.com", :unknown) }.to raise_error(ArgumentError) + end + + it "should reject invalid cursor" do + expect { described_class.new("example.com", :subscribe_repos, "abc") }.to raise_error(ArgumentError) + end + + it "should handle messages and update cursor" do + firehose = described_class.new("example.com") + event = Struct.new(:data).new("payload") + received = nil + + firehose.on_message { |msg| received = msg } + + message = mock(seq: 99) + Skyfall::Firehose::Message.expects(:new).with("payload").returns(message) + + firehose.send(:handle_message, event) + + received.should eq(message) + firehose.cursor.should eq(99) + end + + it "should clear cursor when no message handler is set" do + firehose = described_class.new("example.com") + event = Struct.new(:data).new("payload") + + firehose.send(:handle_message, event) + + firehose.cursor.should be_nil + end +end diff --git a/spec/jetstream_message_spec.rb b/spec/jetstream_message_spec.rb new file mode 100644 index 0000000..0bc37d5 --- /dev/null +++ b/spec/jetstream_message_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'json' + +describe Skyfall::Jetstream::Message do + it "should parse commit messages" do + json = { + "kind" => "commit", + "did" => "did:example:repo", + "time_us" => 123, + "commit" => { + "collection" => "app.bsky.feed.post", + "rkey" => "key", + "operation" => "create", + "record" => { "text" => "Hello" } + } + } + + message = described_class.new(JSON.dump(json)) + + message.should be_a(Skyfall::Jetstream::CommitMessage) + message.type.should eq(:commit) + message.operation.action.should eq(:create) + end + + it "should parse identity messages" do + json = { + "kind" => "identity", + "did" => "did:example:repo", + "time_us" => 123, + "identity" => { "handle" => "alice.test" } + } + + message = described_class.new(JSON.dump(json)) + + message.should be_a(Skyfall::Jetstream::IdentityMessage) + message.handle.should eq("alice.test") + end + + it "should parse unknown message types" do + json = { "kind" => "mystery", "did" => "did:example:repo", "time_us" => 123 } + + message = described_class.new(JSON.dump(json)) + + message.should be_a(Skyfall::Jetstream::UnknownMessage) + message.unknown?.should be(true) + end + + it "should raise when required fields are missing" do + json = { "kind" => "commit" } + + expect { described_class.new(JSON.dump(json)) }.to raise_error(Skyfall::DecodeError) + end +end diff --git a/spec/jetstream_spec.rb b/spec/jetstream_spec.rb new file mode 100644 index 0000000..38dca15 --- /dev/null +++ b/spec/jetstream_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +describe Skyfall::Jetstream do + it "should build a subscribe url without params" do + stream = described_class.new("example.com") + + stream.send(:build_websocket_url).should eq("wss://example.com/subscribe") + end + + it "should include cursor and params" do + stream = described_class.new("example.com", wanted_collections: :bsky_post, cursor: 42) + + stream.send(:build_websocket_url).should eq("wss://example.com/subscribe?wantedCollections=app.bsky.feed.post&cursor=42") + end + + it "should reject unknown params" do + expect { described_class.new("example.com", unknown: true) }.to raise_error(ArgumentError) + end + + it "should reject invalid dids" do + expect { described_class.new("example.com", wanted_dids: ["bad"]) }.to raise_error(ArgumentError) + end + + it "should reject unsupported options" do + expect { described_class.new("example.com", compress: true) }.to raise_error(ArgumentError) + end + + it "should handle messages and update cursor" do + stream = described_class.new("example.com") + event = Struct.new(:data).new("payload") + received = nil + + stream.on_message { |msg| received = msg } + + message = mock(time_us: 123_456) + Skyfall::Jetstream::Message.expects(:new).with("payload").returns(message) + + stream.send(:handle_message, event) + + received.should eq(message) + stream.cursor.should eq(123_456) + end + + it "should clear cursor when no message handler is set" do + stream = described_class.new("example.com") + event = Struct.new(:data).new("payload") + + stream.send(:handle_message, event) + + stream.cursor.should be_nil + end +end diff --git a/spec/label_spec.rb b/spec/label_spec.rb new file mode 100644 index 0000000..8e894bc --- /dev/null +++ b/spec/label_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +describe Skyfall::Label do + it "should parse a valid label" do + cid = Skyfall::CID.new("b" * 36).to_s + data = { + "ver" => 1, + "src" => "did:example:labeller", + "uri" => "at://did:example:foo/app.bsky.feed.post/123", + "cid" => cid, + "val" => "test", + "cts" => "2024-01-01T00:00:00Z", + "exp" => "2025-01-01T00:00:00Z", + "neg" => true + } + + label = described_class.new(data) + + label.version.should eq(1) + label.authority.should eq("did:example:labeller") + label.subject.should eq("at://did:example:foo/app.bsky.feed.post/123") + label.cid.should be_a(Skyfall::CID) + label.value.should eq("test") + label.negation?.should be(true) + label.created_at.should be_a(Time) + label.expires_at.should be_a(Time) + end + + it "should raise on invalid version" do + data = { "ver" => 2, "src" => "did:example:labeller", "uri" => "did:example:foo" } + + expect { described_class.new(data) }.to raise_error(Skyfall::UnsupportedError) + end + + it "should raise when required fields are missing" do + data = { "ver" => 1 } + + expect { described_class.new(data) }.to raise_error(Skyfall::DecodeError) + end +end diff --git a/spec/operation_spec.rb b/spec/operation_spec.rb new file mode 100644 index 0000000..2129b37 --- /dev/null +++ b/spec/operation_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'stringio' + +describe Skyfall::Firehose::Operation do + it "should expose repo information" do + message = Struct.new(:repo, :raw_record_for_operation).new("did:example:repo", nil) + json = { "path" => "app.bsky.feed.post/123", "action" => "create" } + + op = described_class.new(message, json) + + op.repo.should eq("did:example:repo") + op.did.should eq("did:example:repo") + end + + it "should parse path details" do + message = Struct.new(:repo, :raw_record_for_operation).new("did:example:repo", nil) + json = { "path" => "app.bsky.feed.post/123", "action" => "update" } + + op = described_class.new(message, json) + + op.collection.should eq("app.bsky.feed.post") + op.rkey.should eq("123") + op.uri.should eq("at://did:example:repo/app.bsky.feed.post/123") + op.action.should eq(:update) + op.type.should eq(:bsky_post) + end + + it "should warn once when path is called" do + message = Struct.new(:repo, :raw_record_for_operation).new("did:example:repo", nil) + json = { "path" => "app.bsky.feed.post/123", "action" => "delete" } + + described_class.class_variable_set(:@@path_warning_printed, false) + + op = described_class.new(message, json) + + stderr = StringIO.new + original_stderr = $stderr + $stderr = stderr + + op.path.should eq("app.bsky.feed.post/123") + op.path.should eq("app.bsky.feed.post/123") + + stderr.string.should include("deprecated") + ensure + $stderr = original_stderr + end +end + +describe Skyfall::Jetstream::Operation do + it "should expose repo information" do + message = Struct.new(:repo).new("did:example:repo") + json = { "collection" => "app.bsky.feed.post", "rkey" => "123", "operation" => "create" } + + op = described_class.new(message, json) + + op.repo.should eq("did:example:repo") + op.did.should eq("did:example:repo") + end + + it "should build record details" do + message = Struct.new(:repo).new("did:example:repo") + json = { + "collection" => "app.bsky.feed.post", + "rkey" => "123", + "operation" => "create", + "record" => { "text" => "Hi" } + } + + op = described_class.new(message, json) + + op.collection.should eq("app.bsky.feed.post") + op.rkey.should eq("123") + op.uri.should eq("at://did:example:repo/app.bsky.feed.post/123") + op.action.should eq(:create) + op.raw_record.should eq({ "text" => "Hi" }) + op.type.should eq(:bsky_post) + end + + it "should warn once when path is called" do + message = Struct.new(:repo).new("did:example:repo") + json = { "collection" => "app.bsky.feed.post", "rkey" => "123", "operation" => "delete" } + + described_class.class_variable_set(:@@path_warning_printed, false) + + op = described_class.new(message, json) + + stderr = StringIO.new + original_stderr = $stderr + $stderr = stderr + + op.path.should eq("app.bsky.feed.post/123") + op.path.should eq("app.bsky.feed.post/123") + + stderr.string.should include("deprecated") + ensure + $stderr = original_stderr + end +end diff --git a/spec/skyfall_spec.rb b/spec/skyfall_spec.rb index d1d8db7..1951dae 100644 --- a/spec/skyfall_spec.rb +++ b/spec/skyfall_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -RSpec.describe Skyfall do - it "has a version number" do - expect(Skyfall::VERSION).not_to be nil +describe Skyfall do + it "should have a version number" do + Skyfall::VERSION.should_not be_nil end end diff --git a/spec/stream_spec.rb b/spec/stream_spec.rb new file mode 100644 index 0000000..d273707 --- /dev/null +++ b/spec/stream_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class TestStream < Skyfall::Stream + attr_reader :started, :stopped + + def start_heartbeat_timer + @started = true + end + + def stop_heartbeat_timer + @stopped = true + end +end + +describe Skyfall::Stream do + it "should build a root url from a hostname" do + stream = described_class.new("example.com") + + stream.send(:build_root_url, "example.com").should eq("wss://example.com") + end + + it "should accept ws and wss urls" do + stream = described_class.new("ws://example.com") + + stream.send(:build_root_url, "ws://example.com").should eq("ws://example.com") + end + + it "should reject invalid urls" do + stream = described_class.new("wss://example.com") + + expect { stream.send(:build_root_url, "http://example.com") }.to raise_error(ArgumentError) + end + + it "should reject non-string server values" do + expect { described_class.new(123) }.to raise_error(ArgumentError) + end + + it "should ensure empty paths" do + stream = described_class.new("wss://example.com") + + expect { stream.send(:ensure_empty_path, "wss://example.com/path") }.to raise_error(ArgumentError) + end + + it "should toggle heartbeat timer when enabled" do + stream = TestStream.new("wss://example.com") + stream.instance_variable_set(:@engines_on, true) + stream.instance_variable_set(:@ws, Object.new) + + stream.check_heartbeat = true + + stream.started.should be(true) + end + + it "should stop heartbeat timer when disabled" do + stream = TestStream.new("wss://example.com") + stream.instance_variable_set(:@heartbeat_timer, Object.new) + stream.check_heartbeat = true + + stream.check_heartbeat = false + + stream.stopped.should be(true) + end + + it "should format the version string" do + stream = described_class.new("wss://example.com") + + stream.version_string.should eq("Skyfall/#{Skyfall::VERSION}") + end + + it "should return an inspect string" do + stream = described_class.new("wss://example.com") + + stream.inspect.should include("Skyfall::Stream") + end + + it "should compute reconnect delay with backoff" do + stream = described_class.new("wss://example.com") + stream.instance_variable_set(:@connection_attempts, 3) + + stream.send(:reconnect_delay).should eq(4) + end +end