diff --git a/ruby_event_store-browser/devserver/config.ru b/ruby_event_store-browser/devserver/config.ru index df5a60aac8..47589582a7 100644 --- a/ruby_event_store-browser/devserver/config.ru +++ b/ruby_event_store-browser/devserver/config.ru @@ -17,28 +17,69 @@ sample_data = { some_float_infinity_attribute: 1.0 / 0, } -sample_event_type = - lambda do - namespaces = %w[IdentityAndAccess Subscriptions Payments Accounting Banking Reporting] +module IdentityAndAccess + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end +module Subscriptions + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +module Payments + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +module Accounting + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +module Banking + SomethingHappened = Class.new(RubyEventStore::Event) + SomeoneDidThing = Class.new(RubyEventStore::Event) + ThingConfirmed = Class.new(RubyEventStore::Event) + ThingRejected = Class.new(RubyEventStore::Event) +end + +# Reporting namespace intentionally not defined - these will use event_type metadata for testing +# the events which don't have the event classes available in the namespace + +sample_event_class = + lambda do + namespaces = %w[IdentityAndAccess Subscriptions Payments Accounting Banking] events = %w[SomethingHappened SomeoneDidThing ThingConfirmed ThingRejected] - [namespaces.sample, events.sample].join("::") + namespace = namespaces.sample + event = events.sample + Object.const_get("#{namespace}::#{event}") + end + +sample_event_type_without_class = + lambda do + events = %w[SomethingHappened SomeoneDidThing ThingConfirmed ThingRejected] + "Reporting::#{events.sample}" end event_store.publish( - 90.times.map { RubyEventStore::Event.new(data: sample_data, metadata: { event_type: sample_event_type.call }) }, + 80 + .times + .map { sample_event_class.call.new(data: sample_data) } + + 10.times.map { RubyEventStore::Event.new(data: sample_data, metadata: { event_type: sample_event_type_without_class.call }) }, stream_name: "DummyStream$78", ) -other_event = - RubyEventStore::Event.new( - data: sample_data, - metadata: { - event_type: sample_event_type.call, - correlation_id: "469904c5-46ee-43a3-857f-16a455cfe337", - }, - ) +other_event = sample_event_class.call.new(data: sample_data, metadata: { correlation_id: "469904c5-46ee-43a3-857f-16a455cfe337" }) event_store.publish(other_event, stream_name: "OtherStream$91") 21.times do @@ -46,21 +87,19 @@ event_store.publish(other_event, stream_name: "OtherStream$91") correlation_id: other_event.metadata[:correlation_id] || other_event.event_id, causation_id: other_event.event_id, ) do - event_store.publish( - RubyEventStore::Event.new(data: sample_data, metadata: { event_type: sample_event_type.call }), - stream_name: "DummyStream$79", - ) + event_store.publish(sample_event_class.call.new(data: sample_data), stream_name: "DummyStream$79") end end RELATED_STREAMS_QUERY = ->(stream_name) do - stream_name.start_with?("$by_type_#{sample_event_type.call}") ? %w[all $by_type_#{sample_event_type.call}] : [] + stream_name.start_with?("$by_type_") ? %w[all] : [] end browser_app = RubyEventStore::Browser::App.for( event_store_locator: -> { event_store }, related_streams_query: RELATED_STREAMS_QUERY, + experimental_event_types_query: ->(es) { RubyEventStore::Browser::EventTypesQuerying::DefaultQuery.new(es) }, ) mount_point = "/" diff --git a/ruby_event_store-browser/elm/src/Api.elm b/ruby_event_store-browser/elm/src/Api.elm index b1b597a0fb..5f47fe93b8 100644 --- a/ruby_event_store-browser/elm/src/Api.elm +++ b/ruby_event_store-browser/elm/src/Api.elm @@ -1,4 +1,4 @@ -module Api exposing (Event, PaginatedList, PaginationLink, PaginationLinks, RemoteResource(..), Stream, emptyPaginatedList, eventDecoder, eventsDecoder, getEvent, getEvents, getStream) +module Api exposing (Event, EventType, PaginatedList, PaginationLink, PaginationLinks, RemoteResource(..), Stream, emptyPaginatedList, eventDecoder, eventsDecoder, getEvent, getEventTypes, getEvents, getStream) import Flags exposing (Flags) import Http @@ -65,6 +65,12 @@ type alias Stream = } +type alias EventType = + { eventType : String + , streamName : String + } + + buildUrl : String -> String -> String buildUrl baseUrl id = baseUrl ++ "/" ++ Url.percentEncode id @@ -85,6 +91,10 @@ streamUrl flags streamId = buildUrl (Url.toString flags.apiUrl ++ "/streams") streamId +eventTypesUrl : Flags -> String +eventTypesUrl flags = + Url.toString flags.apiUrl ++ "/event_types" + getEvent : (Result Http.Error Event -> msg) -> Flags -> String -> Cmd msg getEvent msgBuilder flags eventId = @@ -142,6 +152,26 @@ streamDecoder_ = |> optionalAt [ "attributes", "related_streams" ] (maybe (list string)) Nothing +getEventTypes : (Result Http.Error (List EventType) -> msg) -> Flags -> Cmd msg +getEventTypes msgBuilder flags = + Http.get + { url = eventTypesUrl flags + , expect = Http.expectJson msgBuilder eventTypesDecoder + } + + +eventTypesDecoder : Decoder (List EventType) +eventTypesDecoder = + field "data" (list eventTypeDecoder_) + + +eventTypeDecoder_ : Decoder EventType +eventTypeDecoder_ = + succeed EventType + |> requiredAt [ "attributes", "event_type" ] string + |> requiredAt [ "attributes", "stream_name" ] string + + getEvents : (Result Http.Error (PaginatedList Event) -> msg) -> Flags -> String -> Pagination.Specification -> Cmd msg getEvents msgBuilder flags streamId paginationSpecification = Http.get diff --git a/ruby_event_store-browser/elm/src/Main.elm b/ruby_event_store-browser/elm/src/Main.elm index d18aa4cab6..8abcba0349 100644 --- a/ruby_event_store-browser/elm/src/Main.elm +++ b/ruby_event_store-browser/elm/src/Main.elm @@ -9,6 +9,7 @@ import Html exposing (..) import Layout import LinkedTimezones exposing (mapLinkedTimeZone) import Page.ShowEvent +import Page.ShowEventTypes import Page.ShowStream import Route import Task @@ -47,6 +48,7 @@ type Msg | ClickedLink Browser.UrlRequest | GotLayoutMsg Layout.Msg | GotShowEventMsg Page.ShowEvent.Msg + | GotShowEventTypesMsg Page.ShowEventTypes.Msg | GotShowStreamMsg Page.ShowStream.Msg | ReceiveTimeZone (Result String Time.ZoneName) @@ -54,6 +56,7 @@ type Msg type Page = NotFound | ShowEvent Page.ShowEvent.Model + | ShowEventTypes Page.ShowEventTypes.Model | ShowStream Page.ShowStream.Model @@ -128,6 +131,15 @@ update msg model = , Cmd.map GotShowEventMsg subCmd ) + ( GotShowEventTypesMsg showEventTypesUIMsg, ShowEventTypes showEventTypesModel ) -> + let + ( subModel, subCmd ) = + Page.ShowEventTypes.update showEventTypesUIMsg showEventTypesModel + in + ( { model | page = ShowEventTypes subModel } + , Cmd.map GotShowEventTypesMsg subCmd + ) + ( GotLayoutMsg layoutMsg, _ ) -> case model.flags of Nothing -> @@ -212,6 +224,11 @@ navigate model location = Nothing -> ( { model | page = NotFound }, Cmd.none ) + Just Route.ShowEventTypes -> + ( { model | page = ShowEventTypes (Page.ShowEventTypes.initModel flags) } + , Cmd.map GotShowEventTypesMsg (Page.ShowEventTypes.initCmd flags) + ) + Nothing -> ( { model | page = NotFound }, Cmd.none ) @@ -264,5 +281,12 @@ viewPage page selectedTime = in ( Just title, Html.map GotShowEventMsg content ) + ShowEventTypes pageModel -> + let + ( title, content ) = + Page.ShowEventTypes.view pageModel selectedTime + in + ( Just title, Html.map GotShowEventTypesMsg content ) + NotFound -> ( Nothing, Layout.viewNotFound ) diff --git a/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm new file mode 100644 index 0000000000..3f9059d42a --- /dev/null +++ b/ruby_event_store-browser/elm/src/Page/ShowEventTypes.elm @@ -0,0 +1,136 @@ +module Page.ShowEventTypes exposing (Model, Msg(..), initCmd, initModel, update, view) + +import Api +import BrowserTime +import Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (class, href, title) +import Http +import Route +import Url + + +-- MODEL + + +type alias Model = + { eventTypes : List Api.EventType + , flags : Flags + , problems : List Problem + } + + +type Problem + = ServerError String + | FeatureNotEnabled + + +initModel : Flags -> Model +initModel flags = + { eventTypes = [] + , flags = flags + , problems = [] + } + + +-- UPDATE + + +type Msg + = EventTypesFetched (Result Http.Error (List Api.EventType)) + + +initCmd : Flags -> Cmd Msg +initCmd flags = + Api.getEventTypes EventTypesFetched flags + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + EventTypesFetched (Ok result) -> + ( { model | eventTypes = result }, Cmd.none ) + + EventTypesFetched (Err error) -> + let + problem = + case error of + Http.BadStatus 422 -> + FeatureNotEnabled + + _ -> + ServerError "Server error, please check backend logs for details" + in + ( { model | problems = [ problem ] }, Cmd.none ) + + +-- VIEW + + +view : Model -> BrowserTime.TimeZone -> ( String, Html Msg ) +view { eventTypes, problems, flags } _ = + let + title = + "Event Types" + + header = + "Event Types" + in + case problems of + [] -> + ( title + , viewEventTypes flags.rootUrl header eventTypes + ) + + _ -> + ( title + , div [ class "py-8" ] + [ div [] + [ ul [] + (List.map + (\problem -> + case problem of + ServerError error -> + li [] [ text error ] + + FeatureNotEnabled -> + li [ class "text-gray-700" ] + [ p [ class "font-semibold mb-2" ] [ text "Event Types feature is not enabled" ] + , p [ class "text-sm" ] + [ text "This experimental feature must be explicitly enabled when configuring the Browser. " + , text "Please check the documentation for configuration details." + ] + ] + ) + problems + ) + ] + ] + ) + + +viewEventTypes : Url.Url -> String -> List Api.EventType -> Html Msg +viewEventTypes rootUrl header eventTypes = + div [ class "py-8" ] + [ h1 [ class "font-semibold text-2xl mb-4" ] [ text header ] + , if List.isEmpty eventTypes then + p [ class "text-gray-500" ] [ text "No event types found" ] + + else + ul [ class "space-y-2" ] + (List.map (viewEventType rootUrl) eventTypes) + ] + + +viewEventType : Url.Url -> Api.EventType -> Html Msg +viewEventType rootUrl eventType = + li [ class "border-b border-gray-200 py-2" ] + [ span [ class "font-medium" ] [ text eventType.eventType ] + , span [ class "text-gray-500 text-sm mx-2" ] [ text "→" ] + , a + [ href (Route.streamUrl rootUrl eventType.streamName) + , class "text-blue-600 hover:text-blue-800 text-sm" + , title ("View " ++ eventType.eventType ++ " events") + ] + [ text eventType.streamName ] + ] diff --git a/ruby_event_store-browser/elm/src/Route.elm b/ruby_event_store-browser/elm/src/Route.elm index e50aa6a95d..3e02eb6c8e 100644 --- a/ruby_event_store-browser/elm/src/Route.elm +++ b/ruby_event_store-browser/elm/src/Route.elm @@ -12,6 +12,7 @@ import Url.Parser.Query as Query type Route = BrowseEvents String Pagination.Specification | ShowEvent String + | ShowEventTypes decodeLocation : Url.Url -> Url.Url -> Maybe Route @@ -25,6 +26,7 @@ routeParser = [ Url.Parser.map (BrowseEvents "all" Pagination.empty) Url.Parser.top , Url.Parser.map browseEvents (Url.Parser.s "streams" Url.Parser.string Query.string "page[position]" Query.string "page[direction]" Query.string "page[count]") , Url.Parser.map ShowEvent (Url.Parser.s "events" Url.Parser.string) + , Url.Parser.map ShowEventTypes (Url.Parser.s "types") ] diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser.rb b/ruby_event_store-browser/lib/ruby_event_store/browser.rb index c82d1cefce..3ec2d285de 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser.rb @@ -16,3 +16,6 @@ module Browser require_relative "browser/urls" require_relative "browser/gem_source" require_relative "browser/router" +require_relative "browser/event_types_querying" +require_relative "browser/json_api_event_type" +require_relative "browser/get_event_types" diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb index d52b180cc4..7d279b263d 100644 --- a/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/app.rb @@ -14,7 +14,8 @@ def self.for( path: nil, api_url: nil, environment: nil, - related_streams_query: DEFAULT_RELATED_STREAMS_QUERY + related_streams_query: DEFAULT_RELATED_STREAMS_QUERY, + experimental_event_types_query: nil ) warn(<<~WARN) if environment Passing :environment to RubyEventStore::Browser::App.for is deprecated. @@ -62,6 +63,7 @@ def self.for( run App.new( event_store_locator: event_store_locator, related_streams_query: related_streams_query, + experimental_event_types_query: experimental_event_types_query, host: host, root_path: path, api_url: api_url, @@ -69,9 +71,10 @@ def self.for( end end - def initialize(event_store_locator:, related_streams_query:, host:, root_path:, api_url:) + def initialize(event_store_locator:, related_streams_query:, experimental_event_types_query:, host:, root_path:, api_url:) @event_store_locator = event_store_locator @related_streams_query = related_streams_query + @experimental_event_types_query = experimental_event_types_query @routing = Urls.from_configuration(host, root_path, api_url) end @@ -95,8 +98,18 @@ def call(env) page: params["page"], ) end + router.add_route("GET", "/api/event_types") do + if experimental_event_types_query.nil? + feature_not_enabled + else + json GetEventTypes.new( + event_store: event_store, + event_types_query: experimental_event_types_query, + ) + end + end - %w[/ /events/:event_id /streams/:stream_name].each do |starting_route| + %w[/ /events/:event_id /streams/:stream_name /types].each do |starting_route| router.add_route("GET", starting_route) do |_, urls| erb bootstrap_html, browser_js_src: urls.browser_js_url, @@ -116,7 +129,7 @@ def call(env) private - attr_reader :event_store_locator, :related_streams_query, :routing + attr_reader :event_store_locator, :related_streams_query, :experimental_event_types_query, :routing def event_store event_store_locator.call @@ -150,6 +163,10 @@ def not_found [404, {}, []] end + def feature_not_enabled + [422, {}, []] + end + def json(body) [200, { "content-type" => "application/vnd.api+json" }, [JSON.dump(body.to_h)]] end diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb new file mode 100644 index 0000000000..04b306c07c --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + module EventTypesQuerying + end + end +end + +require_relative "event_types_querying/event_type" +require_relative "event_types_querying/default_query" diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb new file mode 100644 index 0000000000..b92f0f84e2 --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/default_query.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + module EventTypesQuerying + class DefaultQuery + def initialize(event_store); end + + def call + all_event_subclasses(RubyEventStore::Event) + .select { |klass| !klass.name.nil? } + .sort_by(&:name) + .uniq(&:name) + .map { |klass| EventType.new(event_type: klass.name, stream_name: "$by_type_#{klass.name}") } + end + + private + + def all_event_subclasses(klass) + klass.subclasses + klass.subclasses.flat_map { |subclass| all_event_subclasses(subclass) } + end + end + end + end +end diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb new file mode 100644 index 0000000000..bca8c2c9ee --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/event_types_querying/event_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + module EventTypesQuerying + EventType = Data.define(:event_type, :stream_name) + end + end +end diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb new file mode 100644 index 0000000000..27d7fd5910 --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/get_event_types.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class GetEventTypes + def initialize(event_store:, event_types_query:) + @event_store = event_store + @event_types_query = event_types_query + end + + def to_h + { + data: event_types.map { |event_type| JsonApiEventType.new(event_type).to_h }, + } + end + + private + + attr_reader :event_store, :event_types_query + + def event_types + query = event_types_query.call(event_store) + query.call + end + end + end +end diff --git a/ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb b/ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb new file mode 100644 index 0000000000..21c9aa3a55 --- /dev/null +++ b/ruby_event_store-browser/lib/ruby_event_store/browser/json_api_event_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class JsonApiEventType + def initialize(event_type) + @event_type = event_type + end + + def to_h + { + id: event_type.event_type, + type: "event_types", + attributes: { + event_type: event_type.event_type, + stream_name: event_type.stream_name, + }, + } + end + + private + + attr_reader :event_type + end + end +end diff --git a/ruby_event_store-browser/spec/api/event_types_spec.rb b/ruby_event_store-browser/spec/api/event_types_spec.rb new file mode 100644 index 0000000000..2e98555935 --- /dev/null +++ b/ruby_event_store-browser/spec/api/event_types_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + ::RSpec.describe Browser do + include Browser::IntegrationHelpers + + specify "returns 422 when feature not enabled" do + api_client.get "/api/event_types" + + expect(api_client.last_response.status).to eq(422) + end + + specify "returns list of event types when feature enabled" do + app = + Browser::App.for( + event_store_locator: -> { event_store }, + experimental_event_types_query: ->(es) { Browser::EventTypesQuerying::DefaultQuery.new(es) }, + ) + + enabled_api_client = ApiClient.new(app, "www.example.com") + # Define some test event classes + test_event_1 = Class.new(RubyEventStore::Event) + stub_const("OrderPlaced", test_event_1) + + test_event_2 = Class.new(RubyEventStore::Event) + stub_const("OrderCancelled", test_event_2) + + enabled_api_client.get "/api/event_types" + + expect(enabled_api_client.last_response).to be_ok + expect(enabled_api_client.parsed_body["data"]).to be_an(Array) + + event_types = enabled_api_client.parsed_body["data"] + order_placed = event_types.find { |et| et["attributes"]["event_type"] == "OrderPlaced" } + order_cancelled = event_types.find { |et| et["attributes"]["event_type"] == "OrderCancelled" } + + expect(order_placed).to match( + { + "id" => "OrderPlaced", + "type" => "event_types", + "attributes" => { + "event_type" => "OrderPlaced", + "stream_name" => "$by_type_OrderPlaced", + }, + }, + ) + + expect(order_cancelled).to match( + { + "id" => "OrderCancelled", + "type" => "event_types", + "attributes" => { + "event_type" => "OrderCancelled", + "stream_name" => "$by_type_OrderCancelled", + }, + }, + ) + end + + specify "uses custom query when provided" do + custom_query = + lambda do |event_store| + query = Object.new + def query.call + [ + RubyEventStore::Browser::EventTypesQuerying::EventType.new( + event_type: "CustomEvent", + stream_name: "$custom_stream", + ), + ] + end + query + end + + app = + Browser::App.for( + event_store_locator: -> { event_store }, + experimental_event_types_query: custom_query, + ) + + custom_api_client = ApiClient.new(app, "www.example.com") + custom_api_client.get "/api/event_types" + + expect(custom_api_client.last_response).to be_ok + expect(custom_api_client.parsed_body["data"]).to match( + [ + { + "id" => "CustomEvent", + "type" => "event_types", + "attributes" => { + "event_type" => "CustomEvent", + "stream_name" => "$custom_stream", + }, + }, + ], + ) + end + + specify "passes event_store to query factory" do + received_event_store = nil + custom_query = + lambda do |es| + received_event_store = es + query = Object.new + def query.call + [ + RubyEventStore::Browser::EventTypesQuerying::EventType.new( + event_type: "TestEvent", + stream_name: "$by_type_TestEvent", + ), + ] + end + query + end + + app = + Browser::App.for( + event_store_locator: -> { event_store }, + experimental_event_types_query: custom_query, + ) + + custom_api_client = ApiClient.new(app, "www.example.com") + custom_api_client.get "/api/event_types" + + expect(received_event_store).to eq(event_store) + end + end +end diff --git a/ruby_event_store-browser/spec/app_spec.rb b/ruby_event_store-browser/spec/app_spec.rb new file mode 100644 index 0000000000..925d85a2d3 --- /dev/null +++ b/ruby_event_store-browser/spec/app_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + ::RSpec.describe Browser do + specify "accepts experimental_event_types_query parameter" do + custom_query = ->(event_store) { [] } + + app = + Browser::App.for( + event_store_locator: -> { Client.new }, + experimental_event_types_query: custom_query, + ) + + expect(app).not_to be_nil + end + + specify "uses DefaultQuery when experimental_event_types_query not provided" do + app = Browser::App.for(event_store_locator: -> { Client.new }) + + expect(app).not_to be_nil + end + end +end diff --git a/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb new file mode 100644 index 0000000000..2ff879206a --- /dev/null +++ b/ruby_event_store-browser/spec/event_types_querying/default_query_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + module EventTypesQuerying + ::RSpec.describe DefaultQuery do + before do + # Define test event classes for shared examples + test_event_1 = Class.new(RubyEventStore::Event) + stub_const("SharedExampleEvent1", test_event_1) + + test_event_2 = Class.new(RubyEventStore::Event) + stub_const("SharedExampleEvent2", test_event_2) + end + + it_behaves_like :event_types_query, -> { DefaultQuery.new(RubyEventStore::Client.new) }.call + + specify "finds all classes inheriting from RubyEventStore::Event" do + event_store = RubyEventStore::Client.new + + # Define some test event classes + test_event_1 = Class.new(RubyEventStore::Event) + stub_const("TestEvent1", test_event_1) + + test_event_2 = Class.new(RubyEventStore::Event) + stub_const("TestEvent2", test_event_2) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("TestEvent1", "TestEvent2") + end + + specify "generates stream names in format $by_type_EVENT_NAME" do + event_store = RubyEventStore::Client.new + + test_event = Class.new(RubyEventStore::Event) + stub_const("OrderPlaced", test_event) + + query = DefaultQuery.new(event_store) + result = query.call + + order_placed_type = result.find { |et| et.event_type == "OrderPlaced" } + expect(order_placed_type.stream_name).to eq("$by_type_OrderPlaced") + end + + specify "returns empty array when no event classes are defined" do + event_store = RubyEventStore::Client.new + + # Stub subclasses to return empty array + allow(RubyEventStore::Event).to receive(:subclasses).and_return([]) + + query = DefaultQuery.new(event_store) + result = query.call + + expect(result).to eq([]) + end + + specify "filters out classes without names" do + event_store = RubyEventStore::Client.new + + # Create an anonymous event class (no name) + anonymous_event_class = Class.new(RubyEventStore::Event) + + # Create a named event class + named_event_class = Class.new(RubyEventStore::Event) + stub_const("NamedEvent", named_event_class) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("NamedEvent") + expect(event_types).not_to include(nil) + end + + specify "filters out non-Event classes" do + event_store = RubyEventStore::Client.new + + # Define event classes + event_class = Class.new(RubyEventStore::Event) + stub_const("MyEvent", event_class) + + # Define non-event class + non_event_class = Class.new + stub_const("NonEvent", non_event_class) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("MyEvent") + expect(event_types).not_to include("NonEvent") + end + + specify "stream name uses class name, not class object" do + event_store = RubyEventStore::Client.new + + event_class = Class.new(RubyEventStore::Event) + stub_const("TestEventClass", event_class) + + query = DefaultQuery.new(event_store) + result = query.call + + test_event_type = result.find { |et| et.event_type == "TestEventClass" } + expect(test_event_type.stream_name).to eq("$by_type_TestEventClass") + expect(test_event_type.stream_name).not_to match(/Class:0x/) + end + + specify "deduplicates classes with same name" do + event_store = RubyEventStore::Client.new + + # Create event class + event_class = Class.new(RubyEventStore::Event) + stub_const("DuplicateEvent", event_class) + + # Create a subclass with the same name (edge case) + # This simulates the rare case where class reloading might cause duplicates + duplicate_class = Class.new(RubyEventStore::Event) + allow(duplicate_class).to receive(:name).and_return("DuplicateEvent") + allow(duplicate_class).to receive(:subclasses).and_return([]) + + # Stub to return the duplicate + allow(RubyEventStore::Event).to receive(:subclasses).and_return([event_class, duplicate_class]) + allow(event_class).to receive(:subclasses).and_return([]) + + query = DefaultQuery.new(event_store) + result = query.call + + duplicate_events = result.select { |et| et.event_type == "DuplicateEvent" } + expect(duplicate_events.count).to eq(1) + end + + specify "finds nested subclasses recursively" do + event_store = RubyEventStore::Client.new + + # Create a base event class + base_event = Class.new(RubyEventStore::Event) + stub_const("BaseEvent", base_event) + + # Create a nested subclass + nested_event = Class.new(base_event) + stub_const("NestedEvent", nested_event) + + # Create a deeply nested subclass + deep_nested_event = Class.new(nested_event) + stub_const("DeepNestedEvent", deep_nested_event) + + query = DefaultQuery.new(event_store) + result = query.call + + event_types = result.map(&:event_type) + expect(event_types).to include("BaseEvent", "NestedEvent", "DeepNestedEvent") + end + + specify "returns event types sorted alphabetically by name" do + event_store = RubyEventStore::Client.new + + # Create events in non-alphabetical order + zebra_event = Class.new(RubyEventStore::Event) + stub_const("ZebraEvent", zebra_event) + + apple_event = Class.new(RubyEventStore::Event) + stub_const("AppleEvent", apple_event) + + middle_event = Class.new(RubyEventStore::Event) + stub_const("MiddleEvent", middle_event) + + query = DefaultQuery.new(event_store) + result = query.call + + # Extract just the test events we care about + test_event_names = + result.map(&:event_type).select { |name| %w[ZebraEvent AppleEvent MiddleEvent].include?(name) } + + expect(test_event_names).to eq(%w[AppleEvent MiddleEvent ZebraEvent]) + end + end + end + end +end diff --git a/ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb b/ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb new file mode 100644 index 0000000000..060c021c74 --- /dev/null +++ b/ruby_event_store-browser/spec/event_types_querying/event_type_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + module EventTypesQuerying + ::RSpec.describe EventType do + specify "can be initialized with event_type and stream_name" do + event_type = EventType.new(event_type: "OrderPlaced", stream_name: "$by_type_OrderPlaced") + + expect(event_type.event_type).to eq("OrderPlaced") + expect(event_type.stream_name).to eq("$by_type_OrderPlaced") + end + + specify "requires event_type keyword argument" do + expect { EventType.new(stream_name: "$by_type_OrderPlaced") }.to raise_error(ArgumentError) + end + + specify "requires stream_name keyword argument" do + expect { EventType.new(event_type: "OrderPlaced") }.to raise_error(ArgumentError) + end + end + end + end +end diff --git a/ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb b/ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb new file mode 100644 index 0000000000..ffa8c8d937 --- /dev/null +++ b/ruby_event_store-browser/spec/event_types_querying/query_interface_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "spec_helper" + +module RubyEventStore + module Browser + module EventTypesQuerying + ::RSpec.describe "Event Types Query Interface" do + specify "defines the interface contract" do + # This spec documents the expected interface for event types query objects. + # Any query object implementing this interface should: + # + # 1. Accept an event_store argument in the initializer + # 2. Respond to #call method + # 3. Return an Array of EventType objects from #call + # + # To verify a query object conforms to this interface, use: + # it_behaves_like :event_types_query, YourQueryClass + end + end + end + end +end diff --git a/ruby_event_store-browser/spec/spec_helper.rb b/ruby_event_store-browser/spec/spec_helper.rb index 8f16413edd..69d6775cf3 100644 --- a/ruby_event_store-browser/spec/spec_helper.rb +++ b/ruby_event_store-browser/spec/spec_helper.rb @@ -7,6 +7,7 @@ require "support/api_client" require "support/csp_app" require "support/integration_helpers" +require "support/shared_examples/event_types_query" require_relative "../../support/helpers/rspec_defaults" require_relative "../../support/helpers/time_enrichment" diff --git a/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb b/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb new file mode 100644 index 0000000000..323d13c0ac --- /dev/null +++ b/ruby_event_store-browser/spec/support/shared_examples/event_types_query.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.shared_examples :event_types_query do |query| + specify "responds to call" do + expect(query).to respond_to(:call) + end + + specify "returns array of EventType objects" do + result = query.call + + expect(result).to be_an(Array) + expect(result.size).to be > 1 + result.each do |event_type| + expect(event_type).to be_a(RubyEventStore::Browser::EventTypesQuerying::EventType) + expect(event_type.event_type).to be_a(String) + expect(event_type.stream_name).to be_a(String) + end + end +end