spree_cm_commissioner 2.9.1.pre.pre4 → 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/product_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/trip.rb +10 -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/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/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/trip_result.rb +3 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +37 -4
|
@@ -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
|
|
@@ -81,7 +81,12 @@ module SpreeCmCommissioner
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def trip_attrs(form)
|
|
84
|
-
{
|
|
84
|
+
{
|
|
85
|
+
allow_booking: form.allow_booking,
|
|
86
|
+
allow_seat_selection: form.allow_seat_selection,
|
|
87
|
+
name: form.name,
|
|
88
|
+
short_name: form.short_name
|
|
89
|
+
}.compact
|
|
85
90
|
end
|
|
86
91
|
|
|
87
92
|
def product_attrs(form)
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
module SpreeCmCommissioner
|
|
3
3
|
class UserAuthenticator
|
|
4
4
|
# :username, :password
|
|
5
|
-
# :id_token
|
|
5
|
+
# :id_token (Firebase social login)
|
|
6
|
+
# :telegram_id_token -> Telegram OIDC login (Firebase/Google social login)
|
|
7
|
+
# :user_id_token (load-test JWT, self-identifying, minted by LoadTest::LoginToken)
|
|
6
8
|
# :fb_access_token
|
|
7
9
|
# :telegram_init_data, :tg_bot
|
|
8
10
|
# :session_id
|
|
@@ -33,9 +35,17 @@ module SpreeCmCommissioner
|
|
|
33
35
|
tenant_id = oauth_application&.tenant_id
|
|
34
36
|
|
|
35
37
|
case flow_type(params)
|
|
38
|
+
when 'load_test_auth'
|
|
39
|
+
# k6 sends the load-test JWT as `user_id_token`; internally it's a login_token (see
|
|
40
|
+
# UserPasswordAuthenticator's self-identifying, token-only path).
|
|
41
|
+
options = { login_token: params[:user_id_token], tenant_id: tenant_id }
|
|
42
|
+
SpreeCmCommissioner::UserPasswordAuthenticator.call(options)
|
|
36
43
|
when 'login_auth'
|
|
37
44
|
options = { login: params[:username], password: params[:password], tenant_id: tenant_id }
|
|
38
45
|
SpreeCmCommissioner::UserPasswordAuthenticator.call(options)
|
|
46
|
+
when 'telegram_oauth_auth'
|
|
47
|
+
options = { id_token: params[:telegram_id_token], tenant_id: tenant_id }
|
|
48
|
+
SpreeCmCommissioner::UserTelegramOauthAuthenticator.call(options)
|
|
39
49
|
when 'social_auth'
|
|
40
50
|
options = { id_token: params[:id_token], tenant_id: tenant_id }
|
|
41
51
|
SpreeCmCommissioner::UserIdTokenAuthenticator.call(options)
|
|
@@ -52,7 +62,12 @@ module SpreeCmCommissioner
|
|
|
52
62
|
end
|
|
53
63
|
|
|
54
64
|
def self.flow_type(params)
|
|
65
|
+
# A separate params key from `id_token` (social login) means we only ever attempt to decode
|
|
66
|
+
# a token that's actually claiming to be a load-test token, instead of decoding every
|
|
67
|
+
# request's id_token to find out.
|
|
68
|
+
return 'load_test_auth' if load_test_id_token?(params[:user_id_token])
|
|
55
69
|
return 'login_auth' if params.key?(:username) && params.key?(:password)
|
|
70
|
+
return 'telegram_oauth_auth' if params[:telegram_id_token].present?
|
|
56
71
|
return 'social_auth' if params.key?(:id_token)
|
|
57
72
|
return 'facebook_auth' if params.key?(:fb_access_token)
|
|
58
73
|
return 'telegram_web_app_auth' if params.key?(:telegram_init_data) && params.key?(:tg_bot)
|
|
@@ -61,6 +76,12 @@ module SpreeCmCommissioner
|
|
|
61
76
|
raise exception(I18n.t('authenticator.invalid_or_missing_params'))
|
|
62
77
|
end
|
|
63
78
|
|
|
79
|
+
# True only for a load-test id_token — it's signed with our own secret, so it decodes here
|
|
80
|
+
# whereas a real Firebase/Google id_token (RS256, Google's keys) returns nil.
|
|
81
|
+
def self.load_test_id_token?(token)
|
|
82
|
+
token.present? && SpreeCmCommissioner::LoadTest::LoginToken.decode(token).present?
|
|
83
|
+
end
|
|
84
|
+
|
|
64
85
|
def self.exception(message)
|
|
65
86
|
Doorkeeper::Errors::DoorkeeperError.new(message)
|
|
66
87
|
end
|
|
@@ -29,8 +29,8 @@ module SpreeCmCommissioner
|
|
|
29
29
|
voting_credit.save!
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
record_transaction(voting_credit, total_paid_vote_credits) if total_paid_vote_credits.positive?
|
|
33
|
+
record_transaction_free(voting_credit, total_free_vote_credits) if total_free_vote_credits.positive?
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
record_free_claims
|
|
@@ -99,6 +99,17 @@ module SpreeCmCommissioner
|
|
|
99
99
|
)
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
def record_transaction_free(vote_credit, total_amount)
|
|
103
|
+
vote_credit.voting_credit_transactions.create!(
|
|
104
|
+
tenant_id: @order.tenant_id,
|
|
105
|
+
action: :free_claim,
|
|
106
|
+
amount: total_amount,
|
|
107
|
+
originator: @order,
|
|
108
|
+
memo: "Free claim for order ##{@order.number}",
|
|
109
|
+
user_total_amount: vote_credit.available_votes
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
102
113
|
def record_free_claims
|
|
103
114
|
return if @order.user_id.blank?
|
|
104
115
|
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
<% if @featured_trips.any? %>
|
|
86
86
|
<% @featured_trips.each do |trip| %>
|
|
87
87
|
<tr id="<%= spree_dom_id trip %>">
|
|
88
|
-
<td><%= link_to trip.
|
|
88
|
+
<td><%= link_to trip.display_name || trip.id, spree.edit_admin_product_path(trip.product) if trip.product %></td>
|
|
89
89
|
<td><%= trip.vendor&.name %></td>
|
|
90
90
|
<td><%= [trip.origin_place&.name, trip.destination_place&.name].compact.join(' to ') %></td>
|
|
91
91
|
<td><%= l(trip.departure_time, format: :short) if trip.departure_time %></td>
|
|
@@ -106,6 +106,43 @@
|
|
|
106
106
|
<%= f.label :phone_number, raw(Spree.t(:phone_number) + required_span_tag) %>
|
|
107
107
|
<%= f.text_field :phone_number, class: 'form-control' %>
|
|
108
108
|
<% end %>
|
|
109
|
+
<% when :guest_contact %>
|
|
110
|
+
<%= f.field_container :contact do %>
|
|
111
|
+
<%= f.label :contact, raw(Spree.t(:contact) + required_span_tag) %>
|
|
112
|
+
<%= f.text_field :contact, class: 'form-control' %>
|
|
113
|
+
<% end %>
|
|
114
|
+
<% when :guest_organization %>
|
|
115
|
+
<%= f.field_container :other_organization do %>
|
|
116
|
+
<%= f.label :other_organization, raw(Spree.t(:organization) + required_span_tag) %>
|
|
117
|
+
<%= f.text_field :other_organization, class: 'form-control' %>
|
|
118
|
+
<% end %>
|
|
119
|
+
<% when :guest_address %>
|
|
120
|
+
<%= f.field_container :address do %>
|
|
121
|
+
<%= f.label :address, raw(Spree.t(:address) + required_span_tag) %>
|
|
122
|
+
<%= f.text_field :address, class: 'form-control' %>
|
|
123
|
+
<% end %>
|
|
124
|
+
<% when :guest_social_contact %>
|
|
125
|
+
<div class="row">
|
|
126
|
+
<div class="col-12 col-lg-6">
|
|
127
|
+
<%= f.field_container :social_contact_platform do %>
|
|
128
|
+
<%= f.label :social_contact_platform, raw(Spree.t(:social_contact_platform) + required_span_tag) %>
|
|
129
|
+
<%= f.select :social_contact_platform,
|
|
130
|
+
SpreeCmCommissioner::Guest.social_contact_platforms.keys.map { |platform| [platform.to_s.capitalize, platform] },
|
|
131
|
+
{ include_blank: true }, class: 'fullwidth select2' %>
|
|
132
|
+
<% end %>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="col-12 col-lg-6">
|
|
135
|
+
<%= f.field_container :social_contact do %>
|
|
136
|
+
<%= f.label :social_contact, raw(Spree.t(:social_contact) + required_span_tag) %>
|
|
137
|
+
<%= f.text_field :social_contact, class: 'form-control' %>
|
|
138
|
+
<% end %>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<% when :guest_expectation %>
|
|
142
|
+
<%= f.field_container :expectation do %>
|
|
143
|
+
<%= f.label :expectation, raw(Spree.t(:expectation) + required_span_tag) %>
|
|
144
|
+
<%= f.text_area :expectation, class: 'form-control', rows: 3 %>
|
|
145
|
+
<% end %>
|
|
109
146
|
<% end %>
|
|
110
147
|
<% end %>
|
|
111
148
|
</div>
|
|
@@ -23,4 +23,11 @@
|
|
|
23
23
|
admin_system_maintenance_tasks_path,
|
|
24
24
|
class: "nav-link #{'active' if current == :maintenance_tasks}" %>
|
|
25
25
|
<% end %>
|
|
26
|
+
|
|
27
|
+
<%= content_tag :li, class: 'nav-item' do %>
|
|
28
|
+
<%= link_to_with_icon 'speedometer.svg',
|
|
29
|
+
Spree.t('load_test.nav_label'),
|
|
30
|
+
admin_system_load_testing_index_path,
|
|
31
|
+
class: "nav-link #{'active' if current == :load_testing}" %>
|
|
32
|
+
<% end %>
|
|
26
33
|
<% end %>
|