archipelago-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +79 -0
  5. data/Rakefile +17 -0
  6. data/app/controllers/archipelago/application_controller.rb +10 -0
  7. data/app/controllers/archipelago/islands_controller.rb +158 -0
  8. data/config/database.yml +5 -0
  9. data/config/routes.rb +6 -0
  10. data/lib/archipelago/action.rb +121 -0
  11. data/lib/archipelago/broadcasts.rb +17 -0
  12. data/lib/archipelago/channel.rb +36 -0
  13. data/lib/archipelago/configuration.rb +23 -0
  14. data/lib/archipelago/context.rb +14 -0
  15. data/lib/archipelago/engine.rb +21 -0
  16. data/lib/archipelago/params_dsl.rb +143 -0
  17. data/lib/archipelago/registry.rb +25 -0
  18. data/lib/archipelago/resolver.rb +54 -0
  19. data/lib/archipelago/response.rb +35 -0
  20. data/lib/archipelago/security/origin_validator.rb +32 -0
  21. data/lib/archipelago/security/redirect_validator.rb +30 -0
  22. data/lib/archipelago/test_helpers.rb +31 -0
  23. data/lib/archipelago/view_helper.rb +31 -0
  24. data/lib/archipelago-rails.rb +3 -0
  25. data/lib/archipelago.rb +71 -0
  26. data/lib/generators/archipelago/install/install_generator.rb +43 -0
  27. data/lib/generators/archipelago/install/react/react_generator.rb +380 -0
  28. data/lib/generators/archipelago/install/react/templates/entry.js.tt +13 -0
  29. data/lib/generators/archipelago/install/react/templates/generate_registry.mjs.tt +96 -0
  30. data/lib/generators/archipelago/install/react_generator.rb +3 -0
  31. data/lib/generators/archipelago/install_generator.rb +3 -0
  32. data/lib/generators/archipelago/island/island_generator.rb +44 -0
  33. data/lib/generators/archipelago/island/templates/action.rb.tt +11 -0
  34. data/lib/generators/archipelago/island/templates/component.tsx.tt +14 -0
  35. data/lib/generators/archipelago/island_generator.rb +3 -0
  36. data/test/archipelago/action_test.rb +136 -0
  37. data/test/archipelago/broadcasts_test.rb +29 -0
  38. data/test/archipelago/channel_test.rb +15 -0
  39. data/test/archipelago/notifications_test.rb +60 -0
  40. data/test/archipelago/origin_validator_test.rb +36 -0
  41. data/test/archipelago/params_dsl_test.rb +51 -0
  42. data/test/archipelago/redirect_validator_test.rb +28 -0
  43. data/test/archipelago/resolver_test.rb +80 -0
  44. data/test/archipelago/response_test.rb +30 -0
  45. data/test/archipelago/view_helper_test.rb +32 -0
  46. data/test/controllers/islands_controller_test.rb +115 -0
  47. data/test/generators/install_generator_test.rb +26 -0
  48. data/test/generators/island_generator_test.rb +20 -0
  49. data/test/generators/react_install_generator_test.rb +67 -0
  50. data/test/test_helper.rb +35 -0
  51. metadata +180 -0
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+ require "active_model"
5
+ begin
6
+ require "active_record"
7
+ rescue LoadError
8
+ module ActiveRecord
9
+ class RecordInvalid < StandardError
10
+ attr_reader :record
11
+
12
+ def initialize(record)
13
+ @record = record
14
+ super("Record invalid")
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ class ActionTest < ArchipelagoTestCase
21
+ DummyContext = Struct.new(:user, :request, :params, :session)
22
+
23
+ class ForbiddenAction < Archipelago::Action
24
+ authorize { false }
25
+
26
+ def perform
27
+ props ok: true
28
+ end
29
+ end
30
+
31
+ class MissingAuthorizationAction < Archipelago::Action
32
+ def perform
33
+ props ok: true
34
+ end
35
+ end
36
+
37
+ class RedirectAction < Archipelago::Action
38
+ authorize { true }
39
+
40
+ def perform
41
+ redirect_to "https://evil.test/redirect"
42
+ end
43
+ end
44
+
45
+ class BroadcastAction < Archipelago::Action
46
+ authorize { true }
47
+
48
+ def perform
49
+ props members: [1, 2]
50
+ end
51
+ end
52
+
53
+ class ValidRedirectAction < Archipelago::Action
54
+ authorize { true }
55
+
56
+ def perform
57
+ redirect_to "/teams/1"
58
+ end
59
+ end
60
+
61
+ class RecordModel
62
+ include ActiveModel::Model
63
+
64
+ attr_accessor :email
65
+
66
+ validates :email, presence: true
67
+ end
68
+
69
+ class RecordInvalidAction < Archipelago::Action
70
+ authorize { true }
71
+
72
+ def perform
73
+ model = RecordModel.new(email: nil)
74
+ model.valid?
75
+ raise ActiveRecord::RecordInvalid.new(model)
76
+ end
77
+ end
78
+
79
+ def test_authorization_failure_returns_forbidden
80
+ payload = ForbiddenAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
81
+
82
+ assert_equal({ status: "forbidden" }, payload)
83
+ end
84
+
85
+ def test_missing_authorization_raises_when_required
86
+ Archipelago.configure { |config| config.authorize_by_default = true }
87
+
88
+ assert_raises(Archipelago::MissingAuthorization) do
89
+ MissingAuthorizationAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
90
+ end
91
+ end
92
+
93
+ def test_invalid_redirect_raises
94
+ assert_raises(Archipelago::InvalidRedirect) do
95
+ RedirectAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
96
+ end
97
+ end
98
+
99
+ def test_relative_redirect_is_allowed
100
+ payload = ValidRedirectAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
101
+
102
+ assert_equal "redirect", payload[:status]
103
+ assert_equal "/teams/1", payload[:location]
104
+ end
105
+
106
+ def test_broadcasts_when_stream_param_present
107
+ captured = nil
108
+ original_broadcast = Archipelago.method(:broadcast)
109
+ previous_verbose, $VERBOSE = $VERBOSE, nil
110
+
111
+ Archipelago.singleton_class.define_method(:broadcast) do |stream_name, props:, version:|
112
+ captured = [stream_name, props, version]
113
+ end
114
+
115
+ begin
116
+ BroadcastAction.new(
117
+ ctx: DummyContext.new(nil, nil, nil, nil),
118
+ raw_params: { __stream: "TeamMembers:1" }
119
+ ).call
120
+ ensure
121
+ Archipelago.singleton_class.define_method(:broadcast, original_broadcast)
122
+ $VERBOSE = previous_verbose
123
+ end
124
+
125
+ assert_equal "TeamMembers:1", captured[0]
126
+ assert_equal({ members: [1, 2] }, captured[1])
127
+ assert_kind_of Integer, captured[2]
128
+ end
129
+
130
+ def test_maps_record_invalid_errors
131
+ payload = RecordInvalidAction.new(ctx: DummyContext.new(nil, nil, nil, nil), raw_params: {}).call
132
+
133
+ assert_equal "error", payload[:status]
134
+ assert_equal ["Email can't be blank"], payload[:errors]["email"]
135
+ end
136
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class BroadcastsTest < ArchipelagoTestCase
6
+ ServerStub = Struct.new(:broadcasts) do
7
+ def broadcast(stream_name, payload)
8
+ broadcasts << [stream_name, payload]
9
+ end
10
+ end
11
+
12
+ def test_broadcasts_ok_payload_with_version
13
+ server = ServerStub.new([])
14
+ original_server_method = ActionCable.method(:server)
15
+ previous_verbose, $VERBOSE = $VERBOSE, nil
16
+
17
+ ActionCable.singleton_class.define_method(:server) { server }
18
+
19
+ begin
20
+ payload = Archipelago::Broadcasts.broadcast("TeamMembers:1", props: { members: [1] }, version: 10)
21
+
22
+ assert_equal "ok", payload[:status]
23
+ assert_equal ["TeamMembers:1", payload], server.broadcasts.first
24
+ ensure
25
+ ActionCable.singleton_class.define_method(:server, original_server_method)
26
+ $VERBOSE = previous_verbose
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ChannelTest < ArchipelagoTestCase
6
+ def test_stream_pattern_accepts_safe_names
7
+ assert_match Archipelago::IslandChannel::STREAM_PATTERN, "TeamMembers:1"
8
+ assert_match Archipelago::IslandChannel::STREAM_PATTERN, "teams:abc_123"
9
+ end
10
+
11
+ def test_stream_pattern_rejects_unsafe_names
12
+ refute_match Archipelago::IslandChannel::STREAM_PATTERN, "../etc/passwd"
13
+ refute_match Archipelago::IslandChannel::STREAM_PATTERN, "team members"
14
+ end
15
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ module Islands
6
+ module NotificationDemo
7
+ class Run < Archipelago::Action
8
+ authorize { true }
9
+
10
+ def perform
11
+ props message: "ok"
12
+ end
13
+ end
14
+
15
+ class InvalidRun < Archipelago::Action
16
+ param :email, :string, required: true
17
+ authorize { true }
18
+
19
+ def perform
20
+ props message: "nope"
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ class NotificationsTest < ArchipelagoTestCase
27
+ DummyContext = Struct.new(:user, :request, :params, :session)
28
+
29
+ def test_emits_resolve_notification
30
+ payloads = []
31
+
32
+ callback = lambda do |_name, _start, _finish, _id, payload|
33
+ payloads << payload
34
+ end
35
+
36
+ ActiveSupport::Notifications.subscribed(callback, "archipelago.action.resolve") do
37
+ Archipelago::Resolver.new.resolve(component: "NotificationDemo", operation: "run")
38
+ end
39
+
40
+ assert_equal "NotificationDemo", payloads.first[:component]
41
+ assert_equal "run", payloads.first[:operation]
42
+ end
43
+
44
+ def test_emits_error_notification_for_validation_failure
45
+ payloads = []
46
+
47
+ callback = lambda do |_name, _start, _finish, _id, payload|
48
+ payloads << payload
49
+ end
50
+
51
+ ActiveSupport::Notifications.subscribed(callback, "archipelago.action.error") do
52
+ Islands::NotificationDemo::InvalidRun.new(
53
+ ctx: DummyContext.new(nil, nil, nil, nil),
54
+ raw_params: {}
55
+ ).call
56
+ end
57
+
58
+ assert_equal "validation", payloads.first[:reason]
59
+ end
60
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class OriginValidatorTest < ArchipelagoTestCase
6
+ RequestStub = Struct.new(:headers, :protocol, :host, :port)
7
+
8
+ def test_skips_when_strict_mode_disabled
9
+ config = Archipelago::Configuration.new
10
+ config.strict_origin_check = false
11
+
12
+ request = RequestStub.new({ "Origin" => "https://example.com" }, "https://", "example.com", 443)
13
+
14
+ assert Archipelago::Security::OriginValidator.new(request, configuration: config).validate!
15
+ end
16
+
17
+ def test_allows_matching_origin
18
+ config = Archipelago::Configuration.new
19
+ config.strict_origin_check = true
20
+
21
+ request = RequestStub.new({ "Origin" => "https://example.com" }, "https://", "example.com", 443)
22
+
23
+ assert Archipelago::Security::OriginValidator.new(request, configuration: config).validate!
24
+ end
25
+
26
+ def test_rejects_mismatched_origin
27
+ config = Archipelago::Configuration.new
28
+ config.strict_origin_check = true
29
+
30
+ request = RequestStub.new({ "Origin" => "https://attacker.test" }, "https://", "example.com", 443)
31
+
32
+ assert_raises(Archipelago::InvalidOrigin) do
33
+ Archipelago::Security::OriginValidator.new(request, configuration: config).validate!
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ParamsDSLTest < ArchipelagoTestCase
6
+ class ParamAction < Archipelago::Action
7
+ param :team_id, :integer, required: true
8
+ param :email, :string, required: true, strip: true, downcase: true
9
+ param :notify, :boolean, default: false
10
+
11
+ authorize { true }
12
+
13
+ def perform
14
+ props team_id: team_id, email: email, notify: notify
15
+ end
16
+ end
17
+
18
+ DummyContext = Struct.new(:user, :request, :params, :session)
19
+
20
+ def test_coerces_and_transforms_params
21
+ payload = ParamAction.new(
22
+ ctx: DummyContext.new(nil, nil, nil, nil),
23
+ raw_params: { "team_id" => "10", "email" => " USER@EXAMPLE.COM " }
24
+ ).call
25
+
26
+ assert_equal "ok", payload[:status]
27
+ assert_equal 10, payload[:props][:team_id]
28
+ assert_equal "user@example.com", payload[:props][:email]
29
+ assert_equal false, payload[:props][:notify]
30
+ end
31
+
32
+ def test_reports_required_errors
33
+ payload = ParamAction.new(
34
+ ctx: DummyContext.new(nil, nil, nil, nil),
35
+ raw_params: { "team_id" => "10" }
36
+ ).call
37
+
38
+ assert_equal "error", payload[:status]
39
+ assert_equal ["is required"], payload[:errors]["email"]
40
+ end
41
+
42
+ def test_reports_coercion_errors
43
+ payload = ParamAction.new(
44
+ ctx: DummyContext.new(nil, nil, nil, nil),
45
+ raw_params: { "team_id" => "x", "email" => "a@b.c" }
46
+ ).call
47
+
48
+ assert_equal "error", payload[:status]
49
+ assert_equal ["is invalid"], payload[:errors]["team_id"]
50
+ end
51
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class RedirectValidatorTest < ArchipelagoTestCase
6
+ def test_accepts_relative_path
7
+ validator = Archipelago::Security::RedirectValidator.new
8
+
9
+ assert_equal "/teams/1", validator.validate!("/teams/1")
10
+ end
11
+
12
+ def test_rejects_unlisted_absolute_host
13
+ validator = Archipelago::Security::RedirectValidator.new
14
+
15
+ assert_raises(Archipelago::InvalidRedirect) do
16
+ validator.validate!("https://example.com/path")
17
+ end
18
+ end
19
+
20
+ def test_allows_allowlisted_absolute_host
21
+ config = Archipelago::Configuration.new
22
+ config.allowed_redirect_hosts = ["app.example.com"]
23
+
24
+ validator = Archipelago::Security::RedirectValidator.new(configuration: config)
25
+
26
+ assert_equal "https://app.example.com/path", validator.validate!("https://app.example.com/path")
27
+ end
28
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ module Islands
6
+ module TeamMembers
7
+ class AddMember < Archipelago::Action
8
+ authorize { true }
9
+
10
+ def perform
11
+ props members: []
12
+ end
13
+ end
14
+ end
15
+
16
+ module Admin
17
+ module Users
18
+ class Create < Archipelago::Action
19
+ authorize { true }
20
+
21
+ def perform
22
+ props users: []
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ class ResolverTest < ArchipelagoTestCase
30
+ class CustomHandler < Archipelago::Action
31
+ authorize { true }
32
+
33
+ def perform
34
+ props override: true
35
+ end
36
+ end
37
+
38
+ def setup
39
+ super
40
+ Archipelago.configure { |config| config.root_namespace = "Islands" }
41
+ end
42
+
43
+ def test_resolves_by_convention
44
+ resolved = Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "add_member")
45
+
46
+ assert_equal Islands::TeamMembers::AddMember, resolved
47
+ end
48
+
49
+ def test_resolves_namespaced_component
50
+ resolved = Archipelago::Resolver.new.resolve(component: "Admin__Users", operation: "create")
51
+
52
+ assert_equal Islands::Admin::Users::Create, resolved
53
+ end
54
+
55
+ def test_registry_override_takes_precedence
56
+ Archipelago.map "TeamMembers#add_member" => CustomHandler
57
+
58
+ resolved = Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "add_member")
59
+
60
+ assert_equal CustomHandler, resolved
61
+ end
62
+
63
+ def test_rejects_invalid_component_name
64
+ assert_raises(Archipelago::ResolutionError) do
65
+ Archipelago::Resolver.new.resolve(component: "team_members", operation: "add_member")
66
+ end
67
+ end
68
+
69
+ def test_rejects_invalid_operation_name
70
+ assert_raises(Archipelago::ResolutionError) do
71
+ Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "AddMember")
72
+ end
73
+ end
74
+
75
+ def test_raises_for_missing_handler
76
+ assert_raises(Archipelago::ResolutionError) do
77
+ Archipelago::Resolver.new.resolve(component: "UnknownIsland", operation: "add_member")
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ResponseTest < ArchipelagoTestCase
6
+ def test_ok_payload
7
+ payload = Archipelago::Response.ok(props: { members: [] }, version: 7)
8
+
9
+ assert_equal "ok", payload[:status]
10
+ assert_equal({ members: [] }, payload[:props])
11
+ assert_equal 7, payload[:version]
12
+ end
13
+
14
+ def test_redirect_payload
15
+ payload = Archipelago::Response.redirect(location: "/teams/1")
16
+
17
+ assert_equal({ status: "redirect", location: "/teams/1" }, payload)
18
+ end
19
+
20
+ def test_error_payload
21
+ payload = Archipelago::Response.error(errors: { "email" => ["is invalid"] })
22
+
23
+ assert_equal "error", payload[:status]
24
+ assert_equal ["is invalid"], payload[:errors]["email"]
25
+ end
26
+
27
+ def test_forbidden_payload
28
+ assert_equal({ status: "forbidden" }, Archipelago::Response.forbidden)
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class ViewHelperTest < ArchipelagoTestCase
6
+ ViewContext = Class.new do
7
+ include ActionView::Helpers::TagHelper
8
+ include Archipelago::ViewHelper
9
+ end
10
+
11
+ def test_renders_island_div_with_data_attributes
12
+ html = ViewContext.new.archipelago_island(
13
+ "TeamMembers",
14
+ props: { members: [{ id: 1 }] },
15
+ params: { team_id: 9 },
16
+ instance: "team_9_members",
17
+ stream: true,
18
+ class: "island"
19
+ )
20
+
21
+ assert_includes html, "data-island=\"true\""
22
+ assert_includes html, "data-component=\"TeamMembers\""
23
+ assert_includes html, "data-stream=\"TeamMembers:team_9_members\""
24
+ assert_includes html, "class=\"island\""
25
+ end
26
+
27
+ def test_requires_instance_when_stream_true
28
+ assert_raises(ArgumentError) do
29
+ ViewContext.new.archipelago_island("TeamMembers", props: {}, params: {}, stream: true)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+ require "rails/all"
5
+ require "action_dispatch/testing/integration"
6
+
7
+ unless defined?(ArchipelagoDummyApp)
8
+ class ArchipelagoDummyApp < Rails::Application
9
+ config.root = File.expand_path("../../", __dir__)
10
+ config.eager_load = false
11
+ config.secret_key_base = "test-key"
12
+ config.hosts << "www.example.com"
13
+
14
+ routes.append do
15
+ mount Archipelago::Engine => "/islands"
16
+ end
17
+ end
18
+
19
+ ArchipelagoDummyApp.initialize!
20
+ end
21
+
22
+ module Islands
23
+ module TeamMembers
24
+ class AddMember < Archipelago::Action
25
+ authorize { true }
26
+
27
+ def perform
28
+ props members: [{ id: 1, email: "person@example.com" }]
29
+ end
30
+ end
31
+
32
+ class Forbid < Archipelago::Action
33
+ authorize { false }
34
+
35
+ def perform
36
+ props blocked: false
37
+ end
38
+ end
39
+
40
+ class DebugMeta < Archipelago::Action
41
+ param :team_id, :integer, required: true
42
+ param :email, :string, strip: true, downcase: true, default: "fallback@example.com"
43
+ param :tags, :array, default: -> { [] }
44
+
45
+ authorize { true }
46
+
47
+ def perform
48
+ props ok: true
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ class IslandsControllerTest < ActionDispatch::IntegrationTest
55
+ def setup
56
+ super
57
+ Archipelago.reset_configuration!
58
+ Archipelago.registry.clear!
59
+ end
60
+
61
+ def test_success_response_contract
62
+ post "/islands/TeamMembers/add_member", params: { email: "person@example.com" }, as: :json
63
+
64
+ assert_response :ok
65
+ body = JSON.parse(response.body)
66
+ assert_equal "ok", body["status"]
67
+ assert_equal "person@example.com", body.fetch("props").fetch("members").first.fetch("email")
68
+ end
69
+
70
+ def test_forbidden_response_contract
71
+ post "/islands/TeamMembers/forbid", params: {}, as: :json
72
+
73
+ assert_response :forbidden
74
+ body = JSON.parse(response.body)
75
+ assert_equal "forbidden", body["status"]
76
+ end
77
+
78
+ def test_resolution_failure_is_404
79
+ post "/islands/Unknown/add_member", params: {}, as: :json
80
+
81
+ assert_response :not_found
82
+ end
83
+
84
+ def test_debug_payload_includes_param_metadata_for_registry_actions
85
+ Archipelago.map "TeamMembers#debug_meta" => Islands::TeamMembers::DebugMeta
86
+
87
+ get "/islands/__debug"
88
+
89
+ assert_response :ok
90
+ body = JSON.parse(response.body)
91
+
92
+ entry = body.fetch("registry_actions").find do |candidate|
93
+ candidate["component"] == "TeamMembers" && candidate["operation"] == "debug_meta"
94
+ end
95
+
96
+ refute_nil entry
97
+ assert_equal "Islands::TeamMembers::DebugMeta", entry["handler"]
98
+
99
+ team_id = entry.fetch("params").find { |param| param["name"] == "team_id" }
100
+ assert_equal "integer", team_id["type"]
101
+ assert_equal true, team_id["required"]
102
+ assert_equal({ "provided" => false }, team_id["default"])
103
+
104
+ email = entry.fetch("params").find { |param| param["name"] == "email" }
105
+ assert_equal "string", email["type"]
106
+ assert_equal({ "provided" => true, "value" => "fallback@example.com" }, email["default"])
107
+ assert_equal(
108
+ { "strip" => true, "downcase" => true, "upcase" => false },
109
+ email["transforms"]
110
+ )
111
+
112
+ tags = entry.fetch("params").find { |param| param["name"] == "tags" }
113
+ assert_equal({ "provided" => true, "kind" => "callable" }, tags["default"])
114
+ end
115
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+ require "rails/generators/test_case"
5
+ require "generators/archipelago/install_generator"
6
+
7
+ class InstallGeneratorTest < Rails::Generators::TestCase
8
+ tests Archipelago::Generators::InstallGenerator
9
+ destination File.expand_path("../../tmp", __dir__)
10
+
11
+ setup :prepare_destination
12
+
13
+ def setup
14
+ super
15
+ FileUtils.mkdir_p(File.join(destination_root, "config"))
16
+ File.write(File.join(destination_root, "config/routes.rb"), "Rails.application.routes.draw do\nend\n")
17
+ end
18
+
19
+ def test_generates_initializer_and_islands_dir
20
+ run_generator
21
+
22
+ assert_file "app/islands"
23
+ assert_file "config/initializers/archipelago.rb"
24
+ assert_file "config/routes.rb", /mount Archipelago::Engine => "\/islands"/
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+ require "rails/generators/test_case"
5
+ require "generators/archipelago/island_generator"
6
+
7
+ class IslandGeneratorTest < Rails::Generators::TestCase
8
+ tests Archipelago::Generators::IslandGenerator
9
+ destination File.expand_path("../../tmp", __dir__)
10
+
11
+ setup :prepare_destination
12
+
13
+ def test_generates_action_and_component_files
14
+ run_generator %w[TeamMembers add_member remove_member]
15
+
16
+ assert_file "app/islands/team_members/add_member.rb", /class AddMember < Archipelago::Action/
17
+ assert_file "app/islands/team_members/remove_member.rb", /class RemoveMember < Archipelago::Action/
18
+ assert_file "app/javascript/islands/TeamMembers.tsx", /useIslandProps/
19
+ end
20
+ end