spree_cm_commissioner 2.9.1.pre.pre3 → 2.9.2
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.
- checksums.yaml +4 -4
- data/.env.example +9 -0
- data/Gemfile.lock +2 -2
- data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +7 -0
- data/app/controllers/concerns/spree_cm_commissioner/load_test_account_management.rb +80 -0
- data/app/controllers/concerns/spree_cm_commissioner/load_test_account_scope.rb +46 -0
- data/app/controllers/concerns/spree_cm_commissioner/load_test_login_token_generation.rb +53 -0
- data/app/controllers/spree/admin/guests_controller.rb +4 -2
- data/app/controllers/spree/admin/system/load_test_accounts_controller.rb +24 -0
- data/app/controllers/spree/admin/system/load_test_flows_controller.rb +11 -0
- data/app/controllers/spree/admin/system/load_test_login_tokens_controller.rb +16 -0
- data/app/controllers/spree/admin/system/load_testing_controller.rb +9 -0
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +48 -0
- data/app/controllers/spree/api/v2/storefront/preview_sections_controller.rb +1 -7
- data/app/controllers/spree/api/v2/storefront/telegram_oauth_tokens_controller.rb +28 -0
- data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +49 -0
- data/app/controllers/spree/api/v2/tenant/preview_products_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/preview_sections_controller.rb +1 -7
- data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +11 -0
- data/app/controllers/spree_cm_commissioner/api/v2/storefront/checkout_controller_decorator.rb +0 -2
- data/app/interactors/spree_cm_commissioner/telegram_oauth_id_token_provider.rb +105 -0
- data/app/interactors/spree_cm_commissioner/telegram_oauth_token_exchanger.rb +71 -0
- data/app/interactors/spree_cm_commissioner/user_id_token_authenticator.rb +2 -5
- data/app/interactors/spree_cm_commissioner/user_password_authenticator.rb +23 -1
- data/app/interactors/spree_cm_commissioner/user_telegram_oauth_authenticator.rb +78 -0
- data/app/mailers/spree/order_mailer_decorator.rb +1 -0
- data/app/models/concerns/spree_cm_commissioner/kyc_bitwise.rb +17 -0
- data/app/models/concerns/spree_cm_commissioner/line_items_filter_scope.rb +5 -3
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +11 -0
- data/app/models/concerns/spree_cm_commissioner/product_delegation.rb +2 -0
- data/app/models/spree_cm_commissioner/homepage_section_relatable.rb +25 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +0 -6
- data/app/models/spree_cm_commissioner/payment_method_decorator.rb +2 -0
- data/app/models/spree_cm_commissioner/pricing_model_route.rb +8 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/trip.rb +13 -1
- data/app/models/spree_cm_commissioner/user_decorator.rb +6 -0
- data/app/overrides/spree/admin/payment_methods/index/pagination.html.erb.deface +3 -0
- data/app/overrides/spree/admin/payment_methods/index/preserve_tab_in_search.html.erb.deface +4 -0
- data/app/overrides/spree/admin/products/_form/enable_social_sharing_email.html.erb.deface +13 -0
- data/app/overrides/spree/admin/variants/index/fixture_action.html.erb.deface +9 -0
- data/app/queries/spree_cm_commissioner/multi_leg_trips_query.rb +2 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/trip_result_serializer.rb +2 -2
- data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +3 -1
- data/app/services/spree_cm_commissioner/draft_order/create.rb +86 -0
- data/app/services/spree_cm_commissioner/load_test/create_accounts.rb +68 -0
- data/app/services/spree_cm_commissioner/load_test/destroy_accounts.rb +50 -0
- data/app/services/spree_cm_commissioner/load_test/flow_registry.rb +26 -0
- data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +140 -0
- data/app/services/spree_cm_commissioner/load_test/generate_voting_fixture_data.rb +178 -0
- data/app/services/spree_cm_commissioner/load_test/login_token.rb +46 -0
- data/app/services/spree_cm_commissioner/order/guests/update.rb +56 -0
- data/app/services/spree_cm_commissioner/pricing_models/create_with_rule_groups.rb +9 -3
- data/app/services/spree_cm_commissioner/pricing_models/preview.rb +49 -6
- data/app/services/spree_cm_commissioner/pricing_models/update_with_rule_groups.rb +10 -7
- data/app/services/spree_cm_commissioner/trips/update_single_leg.rb +6 -1
- data/app/services/spree_cm_commissioner/user_authenticator.rb +22 -1
- data/app/services/spree_cm_commissioner/voting_credits/allocate.rb +13 -2
- data/app/views/spree/admin/featured_trips/index.html.erb +1 -1
- data/app/views/spree/admin/guests/_form.html.erb +37 -0
- data/app/views/spree/admin/shared/_system_tabs.html.erb +7 -0
- data/app/views/spree/admin/system/load_test_accounts/_login_tokens_modal.html.erb +52 -0
- data/app/views/spree/admin/system/load_test_accounts/index.html.erb +175 -0
- data/app/views/spree/admin/system/load_test_flows/index.html.erb +66 -0
- data/app/views/spree/admin/system/load_testing/index.html.erb +25 -0
- data/app/views/spree/admin/variants/fixture.html.erb +49 -0
- data/config/initializers/doorkeeper.rb +10 -6
- data/config/initializers/spree_permitted_attributes.rb +30 -0
- data/config/load_test_flows/platform.yml +25 -0
- data/config/load_test_flows/tenant.yml +46 -0
- data/config/locales/en.yml +94 -0
- data/config/locales/km.yml +2 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260630000001_add_trip_to_cm_pricing_model_routes.rb +7 -0
- data/db/migrate/20260703065239_add_position_to_cm_voting_contestants.rb +1 -1
- data/db/migrate/20260708150000_add_name_and_short_name_to_cm_trips.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/pricing_model_trip_factory.rb +11 -0
- data/lib/spree_cm_commissioner/trip_result.rb +3 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +39 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module LoadTest
|
|
3
|
+
# Creates N load-test accounts (tenant-scoped when client_id given, platform accounts
|
|
4
|
+
# otherwise). Each gets a unique random email — "#{email_prefix}_#{32 hex}@#{domain}" — so runs
|
|
5
|
+
# are NOT idempotent: every call adds N fresh accounts to the pool (unlike the old +1/+2/+N
|
|
6
|
+
# scheme, random emails can't be matched to skip or reuse existing rows). Clean up with
|
|
7
|
+
# LoadTest::DestroyAccounts, which removes them by the load_test_account flag, not by email.
|
|
8
|
+
#
|
|
9
|
+
# Accounts are flagged `load_test_account` and can never authenticate with a real password —
|
|
10
|
+
# from any client, mobile app included (see SpreeCmCommissioner::UserPasswordAuthenticator).
|
|
11
|
+
# This service only creates the account pool; it does NOT mint login tokens. Login tokens are
|
|
12
|
+
# stateless, signed JWTs minted on demand from the admin UI (see LoginToken) — an account needs
|
|
13
|
+
# nothing stored on it beyond the flag.
|
|
14
|
+
class CreateAccounts
|
|
15
|
+
prepend ::Spree::ServiceModule::Base
|
|
16
|
+
|
|
17
|
+
def call(client_id: nil, count: 20, email_prefix: 'loadtest', email_domain: nil)
|
|
18
|
+
tenant = resolve_tenant(client_id)
|
|
19
|
+
domain = email_domain.presence || (tenant ? "#{tenant.slug}.test" : 'bookmeplus.test')
|
|
20
|
+
|
|
21
|
+
accounts = Array.new(count) { create_account!(email: unique_email(email_prefix, domain), tenant: tenant) }
|
|
22
|
+
|
|
23
|
+
success(created: accounts.size, accounts: accounts.map { |user| account_row(user) }, email_domain: domain)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
failure(nil, e.message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# "#{prefix}_#{32 hex chars}@#{domain}" — SecureRandom.hex(16) is 16 bytes rendered as 32 hex
|
|
31
|
+
# characters, unique enough that collisions within a tenant are effectively impossible.
|
|
32
|
+
def unique_email(prefix, domain)
|
|
33
|
+
"#{prefix}_#{SecureRandom.hex(16)}@#{domain}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def account_row(user)
|
|
37
|
+
{ email: user.email, user_id: user.id, registered_by: user.registered_by }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_tenant(client_id)
|
|
41
|
+
return nil if client_id.blank?
|
|
42
|
+
|
|
43
|
+
application = Spree::OauthApplication.find_by(uid: client_id)
|
|
44
|
+
raise "No OAuth application found for CLIENT_ID=#{client_id}" unless application
|
|
45
|
+
|
|
46
|
+
tenant = application.tenant
|
|
47
|
+
raise "OAuth application #{client_id} has no tenant associated" unless tenant
|
|
48
|
+
|
|
49
|
+
tenant
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_account!(email:, tenant:)
|
|
53
|
+
account_password = SecureRandom.base58(24)
|
|
54
|
+
|
|
55
|
+
Spree::User.create!(
|
|
56
|
+
email: email,
|
|
57
|
+
password: account_password,
|
|
58
|
+
password_confirmation: account_password,
|
|
59
|
+
confirmed_at: Time.current,
|
|
60
|
+
failed_attempts: 0,
|
|
61
|
+
tenant_id: tenant&.id,
|
|
62
|
+
load_test_account: true,
|
|
63
|
+
registered_by: :system_registered
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module LoadTest
|
|
3
|
+
# Soft-deletes up to N load-test accounts for a scope (acts_as_paranoid). Since accounts now
|
|
4
|
+
# get random emails (see LoadTest::CreateAccounts), they can't be found by reconstructing a
|
|
5
|
+
# deterministic email — they're located by the `load_test_account` flag + tenant instead, the
|
|
6
|
+
# same way the admin UI lists them. Not needed on staging — accounts are reusable across runs.
|
|
7
|
+
class DestroyAccounts
|
|
8
|
+
prepend ::Spree::ServiceModule::Base
|
|
9
|
+
|
|
10
|
+
# Scan cap for the candidate set below — bounds the query while comfortably covering realistic
|
|
11
|
+
# load-test pool sizes (mirrors the admin controller's export scan).
|
|
12
|
+
SCAN_LIMIT = 2000
|
|
13
|
+
|
|
14
|
+
def call(client_id: nil, count: 20, **_ignored)
|
|
15
|
+
tenant = resolve_tenant(client_id)
|
|
16
|
+
|
|
17
|
+
accounts = load_test_accounts(tenant).first(count)
|
|
18
|
+
accounts.each(&:destroy!)
|
|
19
|
+
|
|
20
|
+
success(destroyed: accounts.size, not_found: [count - accounts.size, 0].max)
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
failure(nil, e.message)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# `load_test_account` lives in the `private_metadata` jsonb store (double-encoded as a JSON
|
|
28
|
+
# string scalar), so it can't be filtered in SQL — load the tenant's candidates and filter
|
|
29
|
+
# with the model's own `load_test_account?` in Ruby (same approach as the admin controller).
|
|
30
|
+
def load_test_accounts(tenant)
|
|
31
|
+
Spree::User.where(tenant_id: tenant&.id)
|
|
32
|
+
.order(:id)
|
|
33
|
+
.limit(SCAN_LIMIT)
|
|
34
|
+
.select(&:load_test_account?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resolve_tenant(client_id)
|
|
38
|
+
return nil if client_id.blank?
|
|
39
|
+
|
|
40
|
+
application = Spree::OauthApplication.find_by(uid: client_id)
|
|
41
|
+
raise "No OAuth application found for CLIENT_ID=#{client_id}" unless application
|
|
42
|
+
|
|
43
|
+
tenant = application.tenant
|
|
44
|
+
raise "OAuth application #{client_id} has no tenant associated" unless tenant
|
|
45
|
+
|
|
46
|
+
tenant
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module LoadTest
|
|
3
|
+
# Reads config/load_test_flows/{platform,tenant}.yml — the registry of k6 flows
|
|
4
|
+
# (../../../../load_testing) and where to find real data for each one. Purely descriptive:
|
|
5
|
+
# nothing here queries the DB or generates JSON, it just surfaces the YAML for the
|
|
6
|
+
# "Test Data Sources" card on the admin/organizer Load Testing pages. See those files'
|
|
7
|
+
# header comments for the field shapes.
|
|
8
|
+
module FlowRegistry
|
|
9
|
+
YAML_DIR = File.join(SpreeCmCommissioner::Engine.root, 'config', 'load_test_flows')
|
|
10
|
+
YAML_PATHS = {
|
|
11
|
+
'platform' => File.join(YAML_DIR, 'platform.yml'),
|
|
12
|
+
'tenant' => File.join(YAML_DIR, 'tenant.yml')
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# workflow: 'platform' or 'tenant'. Returns { flow_name => attributes } in file order.
|
|
16
|
+
def self.for_workflow(workflow)
|
|
17
|
+
all.fetch(workflow.to_s, {})
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.all
|
|
21
|
+
@all ||= YAML_PATHS.transform_values { |path| YAML.load_file(path) }
|
|
22
|
+
.deep_symbolize_keys.transform_keys(&:to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module LoadTest
|
|
3
|
+
# Generates the booking_ticket_flow load-test data for one variant so it can be used by the
|
|
4
|
+
# k6 booking flow. An admin picks a ticket variant in the dashboard; this reads its
|
|
5
|
+
# product / variant / event straight off the models and returns the payload the flow needs,
|
|
6
|
+
# plus a "can this actually be booked?" check so the tester knows it will drive the flow.
|
|
7
|
+
#
|
|
8
|
+
# Flow: pick a ticket variant in admin -> copy this payload into
|
|
9
|
+
# load_testing/config/fixture/platform/booking_ticket_flow/<env>.json -> Env.fixture() reads
|
|
10
|
+
# that file when running k6 (load_testing/tests/platform/booking_ticket_flow).
|
|
11
|
+
#
|
|
12
|
+
# Returns success({ data: {...}, issues: [...] }). `issues` is empty when the variant
|
|
13
|
+
# is ready to load-test; otherwise it lists why the booking flow would fail. The same
|
|
14
|
+
# `issues` (plus a `ready` flag) are duplicated into `data[:metadata]` so they travel with
|
|
15
|
+
# the copied JSON — the k6 flow ignores that key, it's there for a human to read later.
|
|
16
|
+
class GenerateBookingTicketData
|
|
17
|
+
prepend ::Spree::ServiceModule::Base
|
|
18
|
+
|
|
19
|
+
def call(variant:)
|
|
20
|
+
product = variant.product
|
|
21
|
+
event = product.event
|
|
22
|
+
issues = issues_for(variant, event)
|
|
23
|
+
|
|
24
|
+
data = {
|
|
25
|
+
event_permalink: event&.permalink,
|
|
26
|
+
product_slug: product.slug,
|
|
27
|
+
product_id: product.id,
|
|
28
|
+
variant_id: variant.id,
|
|
29
|
+
# Informational only — the k6 flow never reads this. Carries the readiness check into
|
|
30
|
+
# the copied JSON itself, so it's visible later without regenerating from admin.
|
|
31
|
+
metadata: {
|
|
32
|
+
ready: issues.empty?,
|
|
33
|
+
issues: issues
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
success(data: data, issues: issues)
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
failure(nil, e.message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Each check returns an explanatory string when it would break the booking flow, or nil when the
|
|
45
|
+
# variant is fine on that axis. Order mirrors the flow: availability first, then checkout shape.
|
|
46
|
+
def issues_for(variant, event)
|
|
47
|
+
product = variant.product
|
|
48
|
+
|
|
49
|
+
[
|
|
50
|
+
inactive_product_issue(product),
|
|
51
|
+
discontinued_variant_issue(variant),
|
|
52
|
+
permanent_stock_issue(variant),
|
|
53
|
+
request_to_book_issue(variant),
|
|
54
|
+
out_of_stock_issue(variant),
|
|
55
|
+
seat_selection_issue(variant, product),
|
|
56
|
+
max_quantity_issue(variant),
|
|
57
|
+
missing_event_issue(event)
|
|
58
|
+
].compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# add-to-cart runs SpreeCmCommissioner::Stock::AvailabilityChecker#can_supply?, which returns false
|
|
62
|
+
# the moment variant.available? is false — before it ever looks at stock. available? is
|
|
63
|
+
# !discontinued? && product.available? (active? && !deleted?), none of which purchasable?
|
|
64
|
+
# (in_stock? || backorderable?) checks. So an inactive product with stock still reports
|
|
65
|
+
# purchasable? == true yet fails add-to-cart with only_active_products_can_be_added_to_cart.
|
|
66
|
+
def inactive_product_issue(product)
|
|
67
|
+
return if product.available?
|
|
68
|
+
|
|
69
|
+
'Product is not active (status is draft/archived, or the product is deleted).'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reported independently of the inactive-product check so a product that is both archived and
|
|
73
|
+
# discontinued surfaces both problems at once, rather than hiding one behind the other.
|
|
74
|
+
def discontinued_variant_issue(variant)
|
|
75
|
+
return unless variant.discontinued?
|
|
76
|
+
|
|
77
|
+
'Variant is discontinued (discontinue_on is in the past, on the variant or its product).'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# accommodation/service/transit variants hold date-scoped inventory: the availability check needs
|
|
81
|
+
# from/to dates to find any inventory item, but the booking flow adds a ticket with no date, so
|
|
82
|
+
# cached_inventory_items comes back empty and add-to-cart fails. This flow is for event tickets.
|
|
83
|
+
def permanent_stock_issue(variant)
|
|
84
|
+
return unless variant.permanent_stock?
|
|
85
|
+
|
|
86
|
+
'Product is a date-based (permanent-stock) type — accommodation / service / transit.'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# request-to-book skips payment (payment_required? is false) and "complete" diverts the order to a
|
|
90
|
+
# request state instead of a paid order — the flow's cash payment + complete no longer represent a
|
|
91
|
+
# real purchase.
|
|
92
|
+
def request_to_book_issue(variant)
|
|
93
|
+
return unless variant.need_confirmation?
|
|
94
|
+
|
|
95
|
+
'Variant is request-to-book (need_confirmation).'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reported on its own axis (stock), independent of whether the product is active — an inactive AND
|
|
99
|
+
# empty variant should surface both. Only skipped for permanent-stock and request-to-book variants,
|
|
100
|
+
# where purchasable? legitimately reads false while the item is still bookable, so this would be a
|
|
101
|
+
# false alarm rather than a real problem.
|
|
102
|
+
def out_of_stock_issue(variant)
|
|
103
|
+
return if variant.permanent_stock? || variant.need_confirmation?
|
|
104
|
+
return if variant.purchasable?
|
|
105
|
+
|
|
106
|
+
'Variant is out of stock (not in stock and not backorderable).'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def seat_selection_issue(variant, product)
|
|
110
|
+
return unless seat_selection_required?(variant, product)
|
|
111
|
+
|
|
112
|
+
'Variant requires seat selection (it has seat blocks).'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def max_quantity_issue(variant)
|
|
116
|
+
max = variant.max_quantity_per_order
|
|
117
|
+
return if max.blank? || max >= 1
|
|
118
|
+
|
|
119
|
+
"Variant max-quantity-per-order is #{max} (less than one ticket)."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def missing_event_issue(event)
|
|
123
|
+
return unless event.nil?
|
|
124
|
+
|
|
125
|
+
'Product has no event taxon — event_permalink is blank.'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Reproduces LineItem#set_required_seat_selection_flag: a ticket needs a seat/block when its
|
|
129
|
+
# variant has seat blocks, gated by the trip's allow_seat_selection when the product is a trip.
|
|
130
|
+
# The load-test flow never picks a seat, so any such variant fails the ensure_all_guests_have_blocks
|
|
131
|
+
# validation at add-to-cart.
|
|
132
|
+
def seat_selection_required?(variant, product)
|
|
133
|
+
return false unless variant.blocks.any?
|
|
134
|
+
return product.trip.allow_seat_selection if product.trip.present?
|
|
135
|
+
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module LoadTest
|
|
3
|
+
# Generates a load-test fixture for one of the tenant voting-related k6 flows, scoped to a
|
|
4
|
+
# single voting session, so it can be copied into load_testing/config/fixture/tenant/.
|
|
5
|
+
# An organizer opens a voting session, picks a flow (voting_flow / aba_topup_vote_flow /
|
|
6
|
+
# cash_on_topup_vote_flow), and this resolves show_slug + voting_session_id straight off the
|
|
7
|
+
# models, plus (for the topup flows) the tenant's active payment method for that gateway.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors GenerateBookingTicketData's shape: success({ data:, issues: }). `issues` lists why
|
|
10
|
+
# the flow isn't ready to load-test (e.g. no ABA payment method configured yet); the same
|
|
11
|
+
# issues are duplicated into data[:metadata] so they travel with the copied JSON.
|
|
12
|
+
class GenerateVotingFixtureData
|
|
13
|
+
prepend ::Spree::ServiceModule::Base
|
|
14
|
+
|
|
15
|
+
FLOWS = %w[voting_flow aba_topup_vote_flow cash_on_topup_vote_flow].freeze
|
|
16
|
+
|
|
17
|
+
# ABA's checkout endpoint is fixed regardless of tenant/environment — only `host` (sandbox
|
|
18
|
+
# vs production) differs, and that comes off the payment method. See
|
|
19
|
+
# Vpago::PaywayV2::Checkout#checkout_url in gems/spree_vpago.
|
|
20
|
+
PAYWAY_CHECKOUT_PATH = '/api/payment-gateway/v1/payments/purchase'.freeze
|
|
21
|
+
|
|
22
|
+
def call(voting_session:, flow:)
|
|
23
|
+
unless FLOWS.include?(flow)
|
|
24
|
+
raise ArgumentError, I18n.t('load_test.generate_voting_fixture_data.unknown_flow', flow: flow, flows: FLOWS.join(', '))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
show = voting_session.show
|
|
28
|
+
issues = []
|
|
29
|
+
issues << I18n.t('load_test.generate_voting_fixture_data.no_show') if show.nil?
|
|
30
|
+
|
|
31
|
+
data = { show_slug: show&.slug, voting_session_id: voting_session.id }
|
|
32
|
+
|
|
33
|
+
if flow == 'voting_flow'
|
|
34
|
+
issues.concat(voting_readiness_issues(voting_session))
|
|
35
|
+
else
|
|
36
|
+
issues.concat(topup_flow_issues(flow, voting_session, data))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
data[:metadata] = { ready: issues.empty?, issues: issues }
|
|
40
|
+
|
|
41
|
+
success(data: data, issues: issues)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
failure(nil, e.message)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Resolves the payment method + (for ABA) its payway payload into `data`, and returns the
|
|
49
|
+
# readiness issues for whichever topup flow this is.
|
|
50
|
+
def topup_flow_issues(flow, voting_session, data)
|
|
51
|
+
issues = []
|
|
52
|
+
payment_method = resolve_payment_method(voting_session.vendor, flow)
|
|
53
|
+
|
|
54
|
+
if payment_method.nil?
|
|
55
|
+
issues << I18n.t('load_test.generate_voting_fixture_data.no_payment_method', label: payment_method_label(flow))
|
|
56
|
+
else
|
|
57
|
+
data[:payment_method_id] = payment_method.id
|
|
58
|
+
issues.concat(aba_payment_method_issues(payment_method, data)) if flow == 'aba_topup_vote_flow'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
issues.concat(vote_package_issues(voting_session))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def aba_payment_method_issues(payment_method, data)
|
|
65
|
+
data[:payway] = payway_payload(payment_method)
|
|
66
|
+
|
|
67
|
+
return [] if payment_method.preferred_host.to_s.match?(/sandbox/i)
|
|
68
|
+
|
|
69
|
+
[
|
|
70
|
+
I18n.t('load_test.generate_voting_fixture_data.sandbox_host_mismatch', host: payment_method.preferred_host.inspect)
|
|
71
|
+
]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Mirrors VotingSession#can_vote? (enabled? && now.between?(opens_at, closes_at) &&
|
|
75
|
+
# !manual_advance?) — the actual gate VoteProcessor#validate! applies to POST votes.
|
|
76
|
+
# Reports only the sub-condition(s) that are actually failing, in plain sentences, rather
|
|
77
|
+
# than dumping the whole rule regardless of which part broke it.
|
|
78
|
+
def can_vote_issues(voting_session)
|
|
79
|
+
issues = []
|
|
80
|
+
|
|
81
|
+
unless voting_session.enabled?
|
|
82
|
+
issues << I18n.t('load_test.generate_voting_fixture_data.session_status_invalid', status: voting_session.status.inspect)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
issues << I18n.t('load_test.generate_voting_fixture_data.manual_advance_never_votes') if voting_session.manual_advance?
|
|
86
|
+
issues << voting_window_issue(voting_session) unless Time.zone.now.between?(voting_session.opens_at, voting_session.closes_at)
|
|
87
|
+
|
|
88
|
+
issues
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def voting_window_issue(voting_session)
|
|
92
|
+
now = Time.zone.now
|
|
93
|
+
|
|
94
|
+
if now < voting_session.opens_at
|
|
95
|
+
I18n.t('load_test.generate_voting_fixture_data.voting_not_opened', opens_at: voting_session.opens_at)
|
|
96
|
+
else
|
|
97
|
+
I18n.t('load_test.generate_voting_fixture_data.voting_already_closed', closes_at: voting_session.closes_at)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Mirrors VoteProcessor#validate! (votes_controller's actual gate on POST .../votes) and
|
|
102
|
+
# VotingContestantsController#index (returns [] with no status filter, which the flow's
|
|
103
|
+
# random contestant pick can't survive).
|
|
104
|
+
def voting_readiness_issues(voting_session)
|
|
105
|
+
issues = can_vote_issues(voting_session)
|
|
106
|
+
|
|
107
|
+
issues << I18n.t('load_test.generate_voting_fixture_data.no_contestants') if voting_session.voting_contestants.empty?
|
|
108
|
+
|
|
109
|
+
issues << I18n.t('load_test.generate_voting_fixture_data.no_eligible_credit') unless VotingCredit.active.eligible(voting_session).exists?
|
|
110
|
+
|
|
111
|
+
issues
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Mirrors VotePackagesController's collection (status: active, scoped to this season) plus
|
|
115
|
+
# Spree's real purchasability gate (Variant#purchasable?) — nothing in checkout ties a
|
|
116
|
+
# package's purchasability to the voting session's own status, so that's not checked here.
|
|
117
|
+
def vote_package_issues(voting_session)
|
|
118
|
+
return [] if resolve_paid_vote_package(voting_session)
|
|
119
|
+
|
|
120
|
+
[I18n.t('load_test.generate_voting_fixture_data.no_vote_package')]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve_paid_vote_package(voting_session)
|
|
124
|
+
vendor = voting_session.vendor
|
|
125
|
+
return nil if vendor.nil?
|
|
126
|
+
|
|
127
|
+
votable_scopes(voting_session).each do |votable_type, votable_id|
|
|
128
|
+
next if votable_id.nil?
|
|
129
|
+
|
|
130
|
+
package = vendor.vote_packages
|
|
131
|
+
.where(status: :active)
|
|
132
|
+
.by_votable(votable_type, votable_id)
|
|
133
|
+
.joins(master: :prices)
|
|
134
|
+
.where.not(spree_prices: { amount: 0 })
|
|
135
|
+
.find { |p| p.vote_variant&.purchasable? }
|
|
136
|
+
|
|
137
|
+
return package if package
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Same three-level fallback VotingCredit::eligible and VotePackagesController use to scope
|
|
144
|
+
# a session/episode/show to its vote packages and credits.
|
|
145
|
+
def votable_scopes(voting_session)
|
|
146
|
+
[
|
|
147
|
+
['SpreeCmCommissioner::VotingSession', voting_session.id],
|
|
148
|
+
['SpreeCmCommissioner::ShowEpisode', voting_session.episode_id],
|
|
149
|
+
['SpreeCmCommissioner::Show', voting_session.show&.id]
|
|
150
|
+
]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resolve_payment_method(vendor, flow)
|
|
154
|
+
type = flow == 'aba_topup_vote_flow' ? Vpago::PaymentMethodDecorator::TYPE_PAYWAY_V2 : Vpago::PaymentMethodDecorator::TYPE_CASH_ON
|
|
155
|
+
|
|
156
|
+
Spree::PaymentMethod.active.where(vendor_id: vendor&.id, type: type).first
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def payment_method_label(flow)
|
|
160
|
+
key = flow == 'aba_topup_vote_flow' ? 'aba' : 'cash_on'
|
|
161
|
+
I18n.t("load_test.generate_voting_fixture_data.payment_method_labels.#{key}")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def payway_payload(payment_method)
|
|
165
|
+
{
|
|
166
|
+
host: payment_method.preferred_host,
|
|
167
|
+
checkout_path: PAYWAY_CHECKOUT_PATH,
|
|
168
|
+
merchant_id: payment_method.preferred_merchant_id,
|
|
169
|
+
api_key: payment_method.preferred_api_key,
|
|
170
|
+
payment_option: payment_method.preferred_payment_option,
|
|
171
|
+
transaction_fee_fix: payment_method.preferred_transaction_fee_fix.to_f,
|
|
172
|
+
transaction_fee_percentage: payment_method.preferred_transaction_fee_percentage.to_f,
|
|
173
|
+
enable_pre_auth: payment_method.enable_pre_auth?
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module LoadTest
|
|
3
|
+
# Stateless, signed login token for load-test accounts — modeled on FB/Google-style login.
|
|
4
|
+
#
|
|
5
|
+
# A self-contained JWT whose claims are all the auth layer needs:
|
|
6
|
+
# sub – the load-test user this token logs in as (what UserPasswordAuthenticator trusts)
|
|
7
|
+
# email – that user's email, so it travels inside the token (k6 needs nothing alongside it)
|
|
8
|
+
# aud – the admin who minted it (audit trail, carried through to the copied JSON)
|
|
9
|
+
# exp – hard expiry; JWT.decode rejects an expired token on its own, no DB lookup needed
|
|
10
|
+
#
|
|
11
|
+
# Nothing is stored on the account and nothing is minted at account-creation time. Tokens are
|
|
12
|
+
# generated on demand from the admin UI (System → Load Testing → Generate Login Tokens) and
|
|
13
|
+
# pasted straight into k6, which sends the JWT as the `user_id_token` param on grant_type=password
|
|
14
|
+
# (UserAuthenticator routes a user_id_token that decodes with our secret to the load-test flow).
|
|
15
|
+
module LoginToken
|
|
16
|
+
TTL = 1.hour
|
|
17
|
+
ALGORITHM = 'HS256'.freeze
|
|
18
|
+
|
|
19
|
+
# Purpose-scoped signing key derived from the app's secret_key_base: deterministic per
|
|
20
|
+
# environment (a token minted now still verifies later on the same box), isolated from every
|
|
21
|
+
# other use of secret_key_base, and zero-config — no credential to set up per environment.
|
|
22
|
+
def self.secret
|
|
23
|
+
Rails.application.key_generator.generate_key('load_test/login_token')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# audience is required, not defaulted — callers must state who's minting the token (the
|
|
27
|
+
# admin's id) rather than letting it silently fall back to something like 'system'.
|
|
28
|
+
def self.generate(user:, audience:)
|
|
29
|
+
now = Time.current
|
|
30
|
+
expires_at = now + TTL
|
|
31
|
+
payload = { sub: user.id, email: user.email, aud: audience.to_s, iat: now.to_i, exp: expires_at.to_i }
|
|
32
|
+
|
|
33
|
+
{ token: JWT.encode(payload, secret, ALGORITHM), expires_at: expires_at }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the decoded payload for a validly-signed, unexpired token; nil otherwise.
|
|
37
|
+
# verify_expiration defaults on, so an expired token raises JWT::ExpiredSignature (a
|
|
38
|
+
# JWT::DecodeError subclass) and is rejected here. aud is informational — not verified.
|
|
39
|
+
def self.decode(token)
|
|
40
|
+
JWT.decode(token, secret, true, algorithm: ALGORITHM).first
|
|
41
|
+
rescue JWT::DecodeError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Order
|
|
3
|
+
module Guests
|
|
4
|
+
# Whitelists submitted guest params down to whatever KYC fields and dynamic
|
|
5
|
+
# fields are actually configured for the guest's line item, then delegates
|
|
6
|
+
# persistence to UpdateGuestService.
|
|
7
|
+
#
|
|
8
|
+
# Expects params shaped as:
|
|
9
|
+
# { first_name: '...', ..., dynamic_fields: { '<dynamic_field_id>' => value_or_option_id_or_array_of_option_ids } }
|
|
10
|
+
class Update
|
|
11
|
+
prepend ::Spree::ServiceModule::Base
|
|
12
|
+
|
|
13
|
+
def call(guest:, params:)
|
|
14
|
+
SpreeCmCommissioner::UpdateGuestService.new(guest, permitted_params(guest, params)).call
|
|
15
|
+
success(guest: guest)
|
|
16
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
17
|
+
failure(guest, e.record.errors.full_messages.to_sentence)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def permitted_params(guest, params)
|
|
23
|
+
attrs = params.to_h.deep_symbolize_keys
|
|
24
|
+
editable_attrs = guest.kyc_fields.flat_map { |field| SpreeCmCommissioner::KycBitwise::KYC_FIELD_ATTRIBUTES[field] || [] }
|
|
25
|
+
|
|
26
|
+
filtered = attrs.slice(*editable_attrs)
|
|
27
|
+
filtered[:guest_dynamic_fields_attributes] = build_dynamic_field_attributes(guest, attrs[:dynamic_fields])
|
|
28
|
+
filtered
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds the guest_dynamic_fields_attributes array UpdateGuestService expects,
|
|
32
|
+
# restricted to dynamic fields actually enabled for this guest's product (one
|
|
33
|
+
# `pluck`/load, not a query per submitted field). Option-based fields (checkbox/
|
|
34
|
+
# radio/selection) submit one or more selected option ids and become one
|
|
35
|
+
# GuestDynamicField row per selected option; UpdateGuestService's own auto-cleanup
|
|
36
|
+
# destroys any existing rows for options that were deselected.
|
|
37
|
+
def build_dynamic_field_attributes(guest, dynamic_fields)
|
|
38
|
+
return [] if dynamic_fields.blank?
|
|
39
|
+
|
|
40
|
+
enabled_fields = guest.line_item.product.dynamic_fields.index_by(&:id)
|
|
41
|
+
|
|
42
|
+
dynamic_fields.flat_map do |dynamic_field_id, value|
|
|
43
|
+
dynamic_field = enabled_fields[dynamic_field_id.to_s.to_i]
|
|
44
|
+
next [] unless dynamic_field
|
|
45
|
+
|
|
46
|
+
if dynamic_field.requires_dynamic_field_options?
|
|
47
|
+
Array(value).compact_blank.map { |option_id| { dynamic_field_id: dynamic_field.id, dynamic_field_option_id: option_id } }
|
|
48
|
+
else
|
|
49
|
+
[{ dynamic_field_id: dynamic_field.id, value: value }]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -3,13 +3,13 @@ module SpreeCmCommissioner
|
|
|
3
3
|
class CreateWithRuleGroups
|
|
4
4
|
prepend ::Spree::ServiceModule::Base
|
|
5
5
|
|
|
6
|
-
def call(vendor:, params:, route: nil, origin_place_id: nil, destination_place_id: nil)
|
|
6
|
+
def call(vendor:, params:, route: nil, trip: nil, origin_place_id: nil, destination_place_id: nil) # rubocop:disable Metrics/ParameterLists
|
|
7
7
|
pricing_model = vendor.pricing_models.new(model_params(params))
|
|
8
8
|
|
|
9
9
|
ApplicationRecord.transaction do
|
|
10
10
|
pricing_model.save!
|
|
11
11
|
build_rule_groups(pricing_model, params[:rule_groups])
|
|
12
|
-
create_route_assignment(pricing_model, route, origin_place_id, destination_place_id) if route.present?
|
|
12
|
+
create_route_assignment(pricing_model, route, trip, origin_place_id, destination_place_id) if route.present?
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
success(pricing_model)
|
|
@@ -41,11 +41,17 @@ module SpreeCmCommissioner
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
# A row is trip-scoped when a trip is given (overrides route pricing for that trip);
|
|
45
|
+
# otherwise route-scoped (unchanged legacy behaviour). Trip-scoped rows take their O-D from
|
|
46
|
+
# the trip so they match the trip's own journey at resolution time.
|
|
47
|
+
def create_route_assignment(pricing_model, route, trip, origin_place_id, destination_place_id)
|
|
48
|
+
origin_place_id = trip.origin_place_id if trip.present?
|
|
49
|
+
destination_place_id = trip.destination_place_id if trip.present?
|
|
45
50
|
return unless origin_place_id.present? && destination_place_id.present?
|
|
46
51
|
|
|
47
52
|
PricingModelRoute.create!(
|
|
48
53
|
route: route,
|
|
54
|
+
trip: trip,
|
|
49
55
|
pricing_model: pricing_model,
|
|
50
56
|
origin_place_id: origin_place_id,
|
|
51
57
|
destination_place_id: destination_place_id
|