hecks 3.14.0 → 5.0.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.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/exe/hecks +1 -1
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +9 -16
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +2 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/cli/project_cli/launcher_source.rb +1 -1
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/app/responses.rb +3 -1
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/first_admin.rb +3 -3
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +7 -2
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value/unwrapping.rb +60 -0
- data/lib/hecks/runtime/value.rb +10 -21
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal} +0 -0
- data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json} +1 -1
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +306 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +151 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/presenters.rs +335 -0
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +574 -229
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +1037 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +555 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -516
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +22 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +244 -114
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +244 -148
- data/rust/src/kernel/mod.rs +47 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/present.rs +261 -0
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +85 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/rust/host/src/approval.rs +0 -652
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
use super::{instances_for, last_refusal, respond, with_id};
|
|
2
2
|
use crate::checkout;
|
|
3
|
-
use crate::
|
|
3
|
+
use crate::clock::unix_now;
|
|
4
4
|
use crate::commerce_ir::{payments_provider, PaymentsProvider};
|
|
5
|
+
use crate::dispatch;
|
|
6
|
+
use crate::host::Host;
|
|
5
7
|
use crate::journal::LineageConfig;
|
|
6
|
-
use crate::lambda_client::LambdaInvoker;
|
|
7
8
|
use crate::payments;
|
|
8
9
|
use serde_json::{json, Value};
|
|
9
10
|
use std::path::Path;
|
|
@@ -31,7 +32,13 @@ fn payment_processor(read: &Value, payments: &PaymentsProvider, reference: &str)
|
|
|
31
32
|
instances_for(read, &payments.instance_prefix())
|
|
32
33
|
.into_iter()
|
|
33
34
|
.find(|(id, _)| id == reference)
|
|
34
|
-
.and_then(|(_, payment)|
|
|
35
|
+
.and_then(|(_, payment)| {
|
|
36
|
+
payment
|
|
37
|
+
.get("processor")
|
|
38
|
+
.and_then(|p| p.get("value"))
|
|
39
|
+
.and_then(|v| v.as_str())
|
|
40
|
+
.map(String::from)
|
|
41
|
+
})
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
// Payment statuses whose registration still holds a seat come from the
|
|
@@ -55,22 +62,40 @@ fn plain_id(raw: Option<&Value>) -> Option<String> {
|
|
|
55
62
|
|
|
56
63
|
/// Seats used per event slug: Registrations not in a seat-freeing state whose Payment (same
|
|
57
64
|
/// reference) is in a seat-holding status.
|
|
58
|
-
fn seat_counts(
|
|
65
|
+
fn seat_counts(
|
|
66
|
+
read: &Value,
|
|
67
|
+
domain: &str,
|
|
68
|
+
payments: &PaymentsProvider,
|
|
69
|
+
) -> std::collections::HashMap<String, usize> {
|
|
59
70
|
let binding = crate::commerce_ir::registrations_binding(domain);
|
|
60
71
|
count_seats(read, &binding, payments)
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/// The seat count of `seat_counts` against an explicit registrations binding.
|
|
64
|
-
fn count_seats(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
fn count_seats(
|
|
76
|
+
read: &Value,
|
|
77
|
+
binding: &crate::commerce_ir::RegistrationsProvider,
|
|
78
|
+
payments: &PaymentsProvider,
|
|
79
|
+
) -> std::collections::HashMap<String, usize> {
|
|
80
|
+
let statuses: std::collections::HashMap<String, String> =
|
|
81
|
+
instances_for(read, &payments.instance_prefix())
|
|
82
|
+
.into_iter()
|
|
83
|
+
.filter_map(|(id, payment)| {
|
|
84
|
+
payment
|
|
85
|
+
.get("status")
|
|
86
|
+
.and_then(|s| s.as_str())
|
|
87
|
+
.map(|s| (id, s.to_string()))
|
|
88
|
+
})
|
|
89
|
+
.collect();
|
|
69
90
|
let mut counts = std::collections::HashMap::new();
|
|
70
91
|
for (id, registration) in instances_for(read, &binding.registration_prefix()) {
|
|
71
|
-
let holds = statuses
|
|
92
|
+
let holds = statuses
|
|
93
|
+
.get(&id)
|
|
94
|
+
.is_some_and(|status| payments.holds_seat.iter().any(|held| held == status));
|
|
72
95
|
let freed = registration_frees_seat(®istration, &binding.frees_seat);
|
|
73
|
-
if let (true, false, Some(event_slug)) =
|
|
96
|
+
if let (true, false, Some(event_slug)) =
|
|
97
|
+
(holds, freed, plain_id(registration.get("event_slug")))
|
|
98
|
+
{
|
|
74
99
|
*counts.entry(event_slug).or_insert(0) += 1;
|
|
75
100
|
}
|
|
76
101
|
}
|
|
@@ -78,13 +103,24 @@ fn count_seats(read: &Value, binding: &crate::commerce_ir::RegistrationsProvider
|
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
/// How many seats one event has used.
|
|
81
|
-
pub(crate) fn seats_taken(
|
|
82
|
-
|
|
106
|
+
pub(crate) fn seats_taken(
|
|
107
|
+
read: &Value,
|
|
108
|
+
domain: &str,
|
|
109
|
+
payments: &PaymentsProvider,
|
|
110
|
+
event_slug: &str,
|
|
111
|
+
) -> usize {
|
|
112
|
+
seat_counts(read, domain, payments)
|
|
113
|
+
.get(event_slug)
|
|
114
|
+
.copied()
|
|
115
|
+
.unwrap_or(0)
|
|
83
116
|
}
|
|
84
117
|
|
|
85
118
|
/// An event's capacity, when it carries a readable one.
|
|
86
119
|
fn event_capacity(event: &Value) -> Option<i64> {
|
|
87
|
-
event
|
|
120
|
+
event
|
|
121
|
+
.get("capacity")
|
|
122
|
+
.and_then(|c| c.get("value"))
|
|
123
|
+
.and_then(serde_json::Value::as_i64)
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
/// A whole-number field read as a bare number or as a single-field value
|
|
@@ -92,7 +128,9 @@ fn event_capacity(event: &Value) -> Option<i64> {
|
|
|
92
128
|
/// never blocks a registration.
|
|
93
129
|
fn whole_number(field: Option<&Value>) -> Option<i64> {
|
|
94
130
|
let field = field?;
|
|
95
|
-
field
|
|
131
|
+
field
|
|
132
|
+
.as_i64()
|
|
133
|
+
.or_else(|| field.get("value").and_then(serde_json::Value::as_i64))
|
|
96
134
|
}
|
|
97
135
|
|
|
98
136
|
/// Whether an event's registration window has shut because its start is
|
|
@@ -102,7 +140,10 @@ fn whole_number(field: Option<&Value>) -> Option<i64> {
|
|
|
102
140
|
/// cutoff is still open. Either field absent, null or unreadable means no
|
|
103
141
|
/// cutoff, the stance the seat check takes.
|
|
104
142
|
fn registration_cutoff_passed(event: &Value, now_secs: i64) -> bool {
|
|
105
|
-
let (Some(starts_at), Some(hours)) = (
|
|
143
|
+
let (Some(starts_at), Some(hours)) = (
|
|
144
|
+
whole_number(event.get("starts_at")),
|
|
145
|
+
whole_number(event.get("registration_cutoff_hours")),
|
|
146
|
+
) else {
|
|
106
147
|
return false;
|
|
107
148
|
};
|
|
108
149
|
now_secs.saturating_add(hours.saturating_mul(3600)) > starts_at
|
|
@@ -115,10 +156,21 @@ fn open_seats(capacity: i64, taken: usize) -> i64 {
|
|
|
115
156
|
|
|
116
157
|
/// Seats still open on one event, never below zero; `None` when there is no
|
|
117
158
|
/// such event or it carries no readable capacity.
|
|
118
|
-
pub(crate) fn seats_left(
|
|
119
|
-
|
|
159
|
+
pub(crate) fn seats_left(
|
|
160
|
+
read: &Value,
|
|
161
|
+
domain: &str,
|
|
162
|
+
payments: &PaymentsProvider,
|
|
163
|
+
event_slug: &str,
|
|
164
|
+
) -> Option<i64> {
|
|
165
|
+
let events = instances_for(
|
|
166
|
+
read,
|
|
167
|
+
&crate::commerce_ir::registrations_binding(domain).event_prefix(),
|
|
168
|
+
);
|
|
120
169
|
let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
|
|
121
|
-
Some(open_seats(
|
|
170
|
+
Some(open_seats(
|
|
171
|
+
event_capacity(event)?,
|
|
172
|
+
seats_taken(read, domain, payments, event_slug),
|
|
173
|
+
))
|
|
122
174
|
}
|
|
123
175
|
|
|
124
176
|
/// The seat figures a caller reads for one event: `capacity` and `seats_left`
|
|
@@ -131,15 +183,33 @@ fn seat_figures(event: &Value, taken: usize) -> Value {
|
|
|
131
183
|
/// The seat figures of every event, keyed by event slug.
|
|
132
184
|
fn all_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider) -> Value {
|
|
133
185
|
let counts = seat_counts(read, domain, payments);
|
|
134
|
-
let events = instances_for(
|
|
135
|
-
|
|
136
|
-
|
|
186
|
+
let events = instances_for(
|
|
187
|
+
read,
|
|
188
|
+
&crate::commerce_ir::registrations_binding(domain).event_prefix(),
|
|
189
|
+
);
|
|
190
|
+
let figures: serde_json::Map<String, Value> = events
|
|
191
|
+
.iter()
|
|
192
|
+
.map(|(slug, event)| {
|
|
193
|
+
(
|
|
194
|
+
slug.clone(),
|
|
195
|
+
seat_figures(event, counts.get(slug).copied().unwrap_or(0)),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
.collect();
|
|
137
199
|
json!({"events": figures})
|
|
138
200
|
}
|
|
139
201
|
|
|
140
202
|
/// The seat figures of one event, with its `slug`; `None` when there is no such event.
|
|
141
|
-
fn one_seat_figures(
|
|
142
|
-
|
|
203
|
+
fn one_seat_figures(
|
|
204
|
+
read: &Value,
|
|
205
|
+
domain: &str,
|
|
206
|
+
payments: &PaymentsProvider,
|
|
207
|
+
event_slug: &str,
|
|
208
|
+
) -> Option<Value> {
|
|
209
|
+
let events = instances_for(
|
|
210
|
+
read,
|
|
211
|
+
&crate::commerce_ir::registrations_binding(domain).event_prefix(),
|
|
212
|
+
);
|
|
143
213
|
let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
|
|
144
214
|
let mut figures = seat_figures(event, seats_taken(read, domain, payments, event_slug));
|
|
145
215
|
figures["slug"] = json!(event_slug);
|
|
@@ -154,7 +224,13 @@ fn seats_path_slug(path: &str) -> Option<&str> {
|
|
|
154
224
|
|
|
155
225
|
/// GET /events/seats and GET /events/<slug>/seats: the seat rule as a read,
|
|
156
226
|
/// so a site never has to count registrations itself.
|
|
157
|
-
async fn seats_route(
|
|
227
|
+
async fn seats_route(
|
|
228
|
+
path: &str,
|
|
229
|
+
client: &Mutex<Client>,
|
|
230
|
+
wasm_path: &Path,
|
|
231
|
+
config: &LineageConfig,
|
|
232
|
+
payments: &PaymentsProvider,
|
|
233
|
+
) -> Value {
|
|
158
234
|
let read = match dispatch::read(client, wasm_path).await {
|
|
159
235
|
Ok(r) => r,
|
|
160
236
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
@@ -163,106 +239,162 @@ async fn seats_route(path: &str, client: &Mutex<Client>, wasm_path: &Path, confi
|
|
|
163
239
|
None => all_seat_figures(&read, &config.domain, payments),
|
|
164
240
|
Some(slug) => match one_seat_figures(&read, &config.domain, payments, slug) {
|
|
165
241
|
Some(figures) => figures,
|
|
166
|
-
None =>
|
|
242
|
+
None => {
|
|
243
|
+
return respond(
|
|
244
|
+
404,
|
|
245
|
+
"application/json",
|
|
246
|
+
&json!({"error": "no such event"}).to_string(),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
167
249
|
},
|
|
168
250
|
};
|
|
169
251
|
respond(200, "application/json", &body.to_string())
|
|
170
252
|
}
|
|
171
253
|
|
|
172
|
-
fn unix_now() -> i64 {
|
|
173
|
-
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
#[allow(clippy::too_many_arguments)]
|
|
177
254
|
// Serves the checkout, registration-payment and webhook routes only when the
|
|
178
255
|
// domain's IR declares `provides "payments"`. Takes the IR as a parameter,
|
|
179
256
|
// not `ir()`, so the gate can be tested against one that lacks the key.
|
|
180
|
-
#[allow(clippy::too_many_arguments)]
|
|
181
257
|
pub(super) async fn payments_routes(
|
|
182
258
|
domain_ir: Option<&Value>,
|
|
183
259
|
method: &str,
|
|
184
260
|
path: &str,
|
|
185
261
|
raw_body: &str,
|
|
186
262
|
stripe_signature: &str,
|
|
187
|
-
|
|
188
|
-
wasm_path: &Path,
|
|
189
|
-
config: &LineageConfig,
|
|
190
|
-
invoker: &dyn LambdaInvoker,
|
|
263
|
+
host: Host<'_>,
|
|
191
264
|
) -> Option<Value> {
|
|
192
265
|
let payments = domain_ir.and_then(payments_provider)?;
|
|
193
|
-
checkout_route(method, path, raw_body, stripe_signature,
|
|
266
|
+
checkout_route(method, path, raw_body, stripe_signature, host, &payments).await
|
|
194
267
|
}
|
|
195
|
-
|
|
196
268
|
pub(super) async fn checkout_route(
|
|
197
269
|
method: &str,
|
|
198
270
|
path: &str,
|
|
199
271
|
raw_body: &str,
|
|
200
272
|
stripe_signature: &str,
|
|
201
|
-
|
|
202
|
-
wasm_path: &Path,
|
|
203
|
-
config: &LineageConfig,
|
|
204
|
-
invoker: &dyn LambdaInvoker,
|
|
273
|
+
host: Host<'_>,
|
|
205
274
|
payments: &PaymentsProvider,
|
|
206
275
|
) -> Option<Value> {
|
|
276
|
+
let Host {
|
|
277
|
+
client,
|
|
278
|
+
wasm_path,
|
|
279
|
+
config,
|
|
280
|
+
..
|
|
281
|
+
} = host;
|
|
207
282
|
match (method, path) {
|
|
208
|
-
("POST", "/registrations") =>
|
|
209
|
-
|
|
210
|
-
|
|
283
|
+
("POST", "/registrations") => Some(
|
|
284
|
+
registrations_route(
|
|
285
|
+
raw_body,
|
|
286
|
+
&payments::PlatformConfig::load().await,
|
|
287
|
+
host,
|
|
288
|
+
payments,
|
|
289
|
+
)
|
|
290
|
+
.await,
|
|
291
|
+
),
|
|
211
292
|
// GET /registrations/:id: read-only, re-derives state server-side
|
|
212
293
|
// for the confirmation and pay pages rather than trusting their own
|
|
213
294
|
// query string.
|
|
214
295
|
("GET", path) if path.starts_with("/registrations/") && !path.ends_with("/complete") => {
|
|
215
296
|
let registration_id = path.trim_start_matches("/registrations/");
|
|
216
|
-
Some(
|
|
297
|
+
Some(
|
|
298
|
+
registration_show_route(registration_id, client, wasm_path, config, payments).await,
|
|
299
|
+
)
|
|
217
300
|
}
|
|
218
301
|
// POST /registrations/:id/complete: mock checkout's Pay/Cancel button,
|
|
219
302
|
// refused unless the Payment's own processor is "mock_stripe". Settles
|
|
220
303
|
// through the same PaymentGateway port as POST /webhooks/stripe.
|
|
221
304
|
("POST", path) if path.starts_with("/registrations/") && path.ends_with("/complete") => {
|
|
222
|
-
let registration_id = path
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
Some(
|
|
305
|
+
let registration_id = path
|
|
306
|
+
.trim_start_matches("/registrations/")
|
|
307
|
+
.trim_end_matches("/complete")
|
|
308
|
+
.trim_end_matches('/');
|
|
309
|
+
Some(registration_complete_route(registration_id, raw_body, host, payments).await)
|
|
227
310
|
}
|
|
311
|
+
("POST", "/webhooks/stripe") => Some(
|
|
312
|
+
webhook_route(
|
|
313
|
+
raw_body,
|
|
314
|
+
stripe_signature,
|
|
315
|
+
&payments::PlatformConfig::load().await,
|
|
316
|
+
host,
|
|
317
|
+
payments,
|
|
318
|
+
)
|
|
319
|
+
.await,
|
|
320
|
+
),
|
|
228
321
|
// POST /events: a mock-payments service schedules a new session
|
|
229
322
|
// when an editor picks a date. Idempotent — Event.find first means
|
|
230
323
|
// a repeat call for the same slug is a no-op, not an error.
|
|
231
|
-
("GET", "/events/seats") =>
|
|
232
|
-
|
|
233
|
-
|
|
324
|
+
("GET", "/events/seats") => {
|
|
325
|
+
Some(seats_route(path, client, wasm_path, config, payments).await)
|
|
326
|
+
}
|
|
327
|
+
("GET", path) if seats_path_slug(path).is_some() => {
|
|
328
|
+
Some(seats_route(path, client, wasm_path, config, payments).await)
|
|
329
|
+
}
|
|
330
|
+
("POST", "/events") => Some(events_route(raw_body, host).await),
|
|
234
331
|
_ => None,
|
|
235
332
|
}
|
|
236
333
|
}
|
|
237
334
|
|
|
238
335
|
/// POST /events. Dispatches `<domain>::Event.Schedule` — Event is this
|
|
239
336
|
/// deploy's own top-level aggregate, not a vendored chapter like Payments.
|
|
240
|
-
async fn events_route(raw_body: &str,
|
|
337
|
+
async fn events_route(raw_body: &str, host: Host<'_>) -> Value {
|
|
338
|
+
let Host {
|
|
339
|
+
client,
|
|
340
|
+
wasm_path,
|
|
341
|
+
config,
|
|
342
|
+
..
|
|
343
|
+
} = host;
|
|
241
344
|
let body: Value = match serde_json::from_str(raw_body) {
|
|
242
345
|
Ok(v) => v,
|
|
243
|
-
Err(e) =>
|
|
346
|
+
Err(e) => {
|
|
347
|
+
return respond(
|
|
348
|
+
400,
|
|
349
|
+
"application/json",
|
|
350
|
+
&json!({"error": format!("invalid JSON: {e}")}).to_string(),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
244
353
|
};
|
|
245
354
|
let Some(slug) = body.get("slug").and_then(|v| v.as_str()) else {
|
|
246
|
-
return respond(
|
|
355
|
+
return respond(
|
|
356
|
+
400,
|
|
357
|
+
"application/json",
|
|
358
|
+
&json!({"error": "missing slug"}).to_string(),
|
|
359
|
+
);
|
|
247
360
|
};
|
|
248
361
|
|
|
249
362
|
let read = match dispatch::read(client, wasm_path).await {
|
|
250
363
|
Ok(r) => r,
|
|
251
364
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
252
365
|
};
|
|
253
|
-
let events = instances_for(
|
|
366
|
+
let events = instances_for(
|
|
367
|
+
&read,
|
|
368
|
+
&crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
|
|
369
|
+
);
|
|
254
370
|
if let Some((_, existing)) = events.iter().find(|(id, _)| id == slug) {
|
|
255
|
-
return respond(
|
|
371
|
+
return respond(
|
|
372
|
+
200,
|
|
373
|
+
"application/json",
|
|
374
|
+
&serde_json::to_string_pretty(&with_id(slug, existing)).unwrap_or_default(),
|
|
375
|
+
);
|
|
256
376
|
}
|
|
257
377
|
|
|
258
378
|
let Some(name) = body.get("name").and_then(|v| v.as_str()) else {
|
|
259
|
-
return respond(
|
|
379
|
+
return respond(
|
|
380
|
+
400,
|
|
381
|
+
"application/json",
|
|
382
|
+
&json!({"error": "missing name"}).to_string(),
|
|
383
|
+
);
|
|
260
384
|
};
|
|
261
|
-
let Some(price_cents) = body.get("price_cents").and_then(
|
|
262
|
-
return respond(
|
|
385
|
+
let Some(price_cents) = body.get("price_cents").and_then(serde_json::Value::as_i64) else {
|
|
386
|
+
return respond(
|
|
387
|
+
400,
|
|
388
|
+
"application/json",
|
|
389
|
+
&json!({"error": "missing price_cents"}).to_string(),
|
|
390
|
+
);
|
|
263
391
|
};
|
|
264
|
-
let Some(capacity) = body.get("capacity").and_then(
|
|
265
|
-
return respond(
|
|
392
|
+
let Some(capacity) = body.get("capacity").and_then(serde_json::Value::as_i64) else {
|
|
393
|
+
return respond(
|
|
394
|
+
400,
|
|
395
|
+
"application/json",
|
|
396
|
+
&json!({"error": "missing capacity"}).to_string(),
|
|
397
|
+
);
|
|
266
398
|
};
|
|
267
399
|
|
|
268
400
|
let args = json!({
|
|
@@ -272,44 +404,83 @@ async fn events_route(raw_body: &str, client: &Mutex<Client>, wasm_path: &Path,
|
|
|
272
404
|
"capacity": {"value": capacity},
|
|
273
405
|
});
|
|
274
406
|
let verb = crate::commerce_ir::registrations_binding(&config.domain).schedule;
|
|
275
|
-
let outcome = match dispatch::handle(
|
|
407
|
+
let outcome = match dispatch::handle(host, &verb, args, None).await {
|
|
276
408
|
Ok(o) => o,
|
|
277
409
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
278
410
|
};
|
|
279
411
|
if !outcome.accepted {
|
|
280
|
-
return respond(
|
|
412
|
+
return respond(
|
|
413
|
+
422,
|
|
414
|
+
"application/json",
|
|
415
|
+
&last_refusal(&outcome.result).to_string(),
|
|
416
|
+
);
|
|
281
417
|
}
|
|
282
418
|
|
|
283
419
|
let read = match dispatch::read(client, wasm_path).await {
|
|
284
420
|
Ok(r) => r,
|
|
285
421
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
286
422
|
};
|
|
287
|
-
let events = instances_for(
|
|
423
|
+
let events = instances_for(
|
|
424
|
+
&read,
|
|
425
|
+
&crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
|
|
426
|
+
);
|
|
288
427
|
let Some((_, event)) = events.iter().find(|(id, _)| id == slug) else {
|
|
289
|
-
return respond(
|
|
428
|
+
return respond(
|
|
429
|
+
500,
|
|
430
|
+
"text/plain",
|
|
431
|
+
"event vanished immediately after being scheduled",
|
|
432
|
+
);
|
|
290
433
|
};
|
|
291
|
-
respond(
|
|
434
|
+
respond(
|
|
435
|
+
201,
|
|
436
|
+
"application/json",
|
|
437
|
+
&serde_json::to_string_pretty(&with_id(slug, event)).unwrap_or_default(),
|
|
438
|
+
)
|
|
292
439
|
}
|
|
293
440
|
|
|
294
441
|
/// GET /registrations/:id: event and attendee fields, plus amount_cents and
|
|
295
442
|
/// payment_status from the same-reference Payment.
|
|
296
|
-
async fn registration_show_route(
|
|
443
|
+
async fn registration_show_route(
|
|
444
|
+
registration_id: &str,
|
|
445
|
+
client: &Mutex<Client>,
|
|
446
|
+
wasm_path: &Path,
|
|
447
|
+
config: &LineageConfig,
|
|
448
|
+
payments: &PaymentsProvider,
|
|
449
|
+
) -> Value {
|
|
297
450
|
let read = match dispatch::read(client, wasm_path).await {
|
|
298
451
|
Ok(r) => r,
|
|
299
452
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
300
453
|
};
|
|
301
|
-
let registrations = instances_for(
|
|
454
|
+
let registrations = instances_for(
|
|
455
|
+
&read,
|
|
456
|
+
&crate::commerce_ir::registrations_binding(&config.domain).registration_prefix(),
|
|
457
|
+
);
|
|
302
458
|
let Some((_, registration)) = registrations.iter().find(|(id, _)| id == registration_id) else {
|
|
303
|
-
return respond(
|
|
459
|
+
return respond(
|
|
460
|
+
404,
|
|
461
|
+
"application/json",
|
|
462
|
+
&json!({"error": "no such registration"}).to_string(),
|
|
463
|
+
);
|
|
304
464
|
};
|
|
305
465
|
let event_slug = registration.get("event_slug").and_then(|v| v.as_str());
|
|
306
|
-
let events = instances_for(
|
|
307
|
-
|
|
466
|
+
let events = instances_for(
|
|
467
|
+
&read,
|
|
468
|
+
&crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
|
|
469
|
+
);
|
|
470
|
+
let event = event_slug
|
|
471
|
+
.and_then(|slug| events.iter().find(|(id, _)| id == slug))
|
|
472
|
+
.map(|(_, e)| e);
|
|
308
473
|
|
|
309
474
|
let payment_instances = instances_for(&read, &payments.instance_prefix());
|
|
310
|
-
let payment = payment_instances
|
|
311
|
-
|
|
312
|
-
|
|
475
|
+
let payment = payment_instances
|
|
476
|
+
.iter()
|
|
477
|
+
.find(|(id, _)| id == registration_id)
|
|
478
|
+
.map(|(_, p)| p);
|
|
479
|
+
|
|
480
|
+
let attendee = registration
|
|
481
|
+
.get("attendee")
|
|
482
|
+
.cloned()
|
|
483
|
+
.unwrap_or_else(|| json!({}));
|
|
313
484
|
respond(200, "application/json", &json!({
|
|
314
485
|
"registration_id": registration_id,
|
|
315
486
|
"event": {
|
|
@@ -332,35 +503,65 @@ async fn registration_show_route(registration_id: &str, client: &Mutex<Client>,
|
|
|
332
503
|
pub(crate) async fn registration_complete_route(
|
|
333
504
|
registration_id: &str,
|
|
334
505
|
raw_body: &str,
|
|
335
|
-
|
|
336
|
-
wasm_path: &Path,
|
|
337
|
-
config: &LineageConfig,
|
|
338
|
-
invoker: &dyn LambdaInvoker,
|
|
506
|
+
host: Host<'_>,
|
|
339
507
|
payments: &PaymentsProvider,
|
|
340
508
|
) -> Value {
|
|
509
|
+
let Host {
|
|
510
|
+
client,
|
|
511
|
+
wasm_path,
|
|
512
|
+
config,
|
|
513
|
+
..
|
|
514
|
+
} = host;
|
|
341
515
|
let body: Value = match serde_json::from_str(raw_body) {
|
|
342
516
|
Ok(v) => v,
|
|
343
|
-
Err(e) =>
|
|
517
|
+
Err(e) => {
|
|
518
|
+
return respond(
|
|
519
|
+
400,
|
|
520
|
+
"application/json",
|
|
521
|
+
&json!({"error": format!("invalid JSON: {e}")}).to_string(),
|
|
522
|
+
)
|
|
523
|
+
}
|
|
344
524
|
};
|
|
345
525
|
let Some(outcome) = body.get("outcome").and_then(|v| v.as_str()) else {
|
|
346
|
-
return respond(
|
|
526
|
+
return respond(
|
|
527
|
+
400,
|
|
528
|
+
"application/json",
|
|
529
|
+
&json!({"error": "missing outcome"}).to_string(),
|
|
530
|
+
);
|
|
347
531
|
};
|
|
348
532
|
if outcome != "succeeded" && outcome != "failed" {
|
|
349
|
-
return respond(
|
|
533
|
+
return respond(
|
|
534
|
+
400,
|
|
535
|
+
"application/json",
|
|
536
|
+
&json!({"error": "outcome must be \"succeeded\" or \"failed\""}).to_string(),
|
|
537
|
+
);
|
|
350
538
|
}
|
|
351
539
|
|
|
352
540
|
let read = match dispatch::read(client, wasm_path).await {
|
|
353
541
|
Ok(r) => r,
|
|
354
542
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
355
543
|
};
|
|
356
|
-
let registrations = instances_for(
|
|
544
|
+
let registrations = instances_for(
|
|
545
|
+
&read,
|
|
546
|
+
&crate::commerce_ir::registrations_binding(&config.domain).registration_prefix(),
|
|
547
|
+
);
|
|
357
548
|
if !registrations.iter().any(|(id, _)| id == registration_id) {
|
|
358
|
-
return respond(
|
|
549
|
+
return respond(
|
|
550
|
+
404,
|
|
551
|
+
"application/json",
|
|
552
|
+
&json!({"error": "no such registration"}).to_string(),
|
|
553
|
+
);
|
|
359
554
|
}
|
|
360
555
|
|
|
361
556
|
let processor = match payment_processor(&read, payments, registration_id) {
|
|
362
557
|
Some(processor) if processor == "mock_stripe" => processor,
|
|
363
|
-
_ =>
|
|
558
|
+
_ => {
|
|
559
|
+
return respond(
|
|
560
|
+
403,
|
|
561
|
+
"application/json",
|
|
562
|
+
&json!({"error": "not available with a real payment processor"}).to_string(),
|
|
563
|
+
)
|
|
564
|
+
}
|
|
364
565
|
};
|
|
365
566
|
let reported_processor = json!({"value": processor});
|
|
366
567
|
// `reference_to Payment, as: :reference` keeps `reference` inside `with:`
|
|
@@ -378,16 +579,20 @@ pub(crate) async fn registration_complete_route(
|
|
|
378
579
|
json!({"reference": reference_fact, "reason": {"value": "declined_at_local_checkout"}, "reported_processor": reported_processor}),
|
|
379
580
|
)
|
|
380
581
|
};
|
|
381
|
-
let outcome_result =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
582
|
+
let outcome_result =
|
|
583
|
+
match dispatch::handle_routed(host, verb, json!(registration_id), facts, None).await {
|
|
584
|
+
Ok(o) => o,
|
|
585
|
+
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
586
|
+
};
|
|
385
587
|
// A repeat Succeeded/Failed on an already-settled Payment is a silent
|
|
386
588
|
// no-op on Payment's own `given`, so it isn't surfaced as a 422 here
|
|
387
589
|
// either.
|
|
388
590
|
if !outcome_result.accepted {
|
|
389
591
|
let refusal = last_refusal(&outcome_result.result);
|
|
390
|
-
let already_settled = refusal
|
|
592
|
+
let already_settled = refusal
|
|
593
|
+
.get("error")
|
|
594
|
+
.and_then(|v| v.as_str())
|
|
595
|
+
.is_some_and(|s| s.contains("pending"));
|
|
391
596
|
if !already_settled {
|
|
392
597
|
return respond(422, "application/json", &refusal.to_string());
|
|
393
598
|
}
|
|
@@ -396,7 +601,7 @@ pub(crate) async fn registration_complete_route(
|
|
|
396
601
|
// Only a payment that settled just now earns a receipt; a repeat click was
|
|
397
602
|
// refused above as already settled.
|
|
398
603
|
if outcome == "succeeded" && outcome_result.accepted {
|
|
399
|
-
super::registration_receipt::
|
|
604
|
+
super::registration_receipt::deliver_receipt(registration_id, host, payments).await;
|
|
400
605
|
}
|
|
401
606
|
|
|
402
607
|
let read = match dispatch::read(client, wasm_path).await {
|
|
@@ -404,8 +609,17 @@ pub(crate) async fn registration_complete_route(
|
|
|
404
609
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
405
610
|
};
|
|
406
611
|
let payment_instances = instances_for(&read, &payments.instance_prefix());
|
|
407
|
-
let status = payment_instances
|
|
408
|
-
|
|
612
|
+
let status = payment_instances
|
|
613
|
+
.iter()
|
|
614
|
+
.find(|(id, _)| id == registration_id)
|
|
615
|
+
.and_then(|(_, p)| p.get("status"))
|
|
616
|
+
.and_then(|v| v.as_str())
|
|
617
|
+
.unwrap_or("");
|
|
618
|
+
respond(
|
|
619
|
+
200,
|
|
620
|
+
"application/json",
|
|
621
|
+
&json!({"registration_id": registration_id, "payment_status": status}).to_string(),
|
|
622
|
+
)
|
|
409
623
|
}
|
|
410
624
|
|
|
411
625
|
// Attendee shape varies per domain, so this forwards the caller's fields
|
|
@@ -436,83 +650,133 @@ fn display_name_from(body: &Value) -> Option<String> {
|
|
|
436
650
|
// POST /registrations. Payment first, then Registration, sharing one
|
|
437
651
|
// reference minted here; `role: None` throughout since the site calls in
|
|
438
652
|
// server-to-server, not as a signed-in user.
|
|
439
|
-
#[allow(clippy::too_many_arguments)]
|
|
440
653
|
pub(crate) async fn registrations_route(
|
|
441
654
|
raw_body: &str,
|
|
442
655
|
platform: &payments::PlatformConfig,
|
|
443
|
-
|
|
444
|
-
wasm_path: &Path,
|
|
445
|
-
config: &LineageConfig,
|
|
446
|
-
invoker: &dyn LambdaInvoker,
|
|
656
|
+
host: Host<'_>,
|
|
447
657
|
payments: &PaymentsProvider,
|
|
448
658
|
) -> Value {
|
|
449
|
-
registrations_route_at(raw_body, platform,
|
|
659
|
+
registrations_route_at(raw_body, platform, host, payments, unix_now()).await
|
|
450
660
|
}
|
|
451
661
|
|
|
452
662
|
// The route with the wall clock passed in, so a test can fix the time the
|
|
453
663
|
// registration cutoff is measured against.
|
|
454
|
-
#[allow(clippy::too_many_arguments)]
|
|
455
664
|
pub(crate) async fn registrations_route_at(
|
|
456
665
|
raw_body: &str,
|
|
457
666
|
platform: &payments::PlatformConfig,
|
|
458
|
-
|
|
459
|
-
wasm_path: &Path,
|
|
460
|
-
config: &LineageConfig,
|
|
461
|
-
invoker: &dyn LambdaInvoker,
|
|
667
|
+
host: Host<'_>,
|
|
462
668
|
payments: &PaymentsProvider,
|
|
463
669
|
now_secs: i64,
|
|
464
670
|
) -> Value {
|
|
671
|
+
let Host {
|
|
672
|
+
client,
|
|
673
|
+
wasm_path,
|
|
674
|
+
config,
|
|
675
|
+
..
|
|
676
|
+
} = host;
|
|
465
677
|
let body: Value = match serde_json::from_str(raw_body) {
|
|
466
678
|
Ok(v) => v,
|
|
467
|
-
Err(e) =>
|
|
679
|
+
Err(e) => {
|
|
680
|
+
return respond(
|
|
681
|
+
400,
|
|
682
|
+
"application/json",
|
|
683
|
+
&json!({"error": format!("invalid JSON: {e}")}).to_string(),
|
|
684
|
+
)
|
|
685
|
+
}
|
|
468
686
|
};
|
|
469
687
|
let Some(event_slug) = body.get("event_slug").and_then(|v| v.as_str()) else {
|
|
470
|
-
return respond(
|
|
688
|
+
return respond(
|
|
689
|
+
400,
|
|
690
|
+
"application/json",
|
|
691
|
+
&json!({"error": "missing event_slug"}).to_string(),
|
|
692
|
+
);
|
|
471
693
|
};
|
|
472
694
|
// Cheap early exits before dispatching Payment.Initiate — full Attendee
|
|
473
695
|
// validation still happens downstream in Registration.Request's own
|
|
474
696
|
// given/invariant checks.
|
|
475
697
|
let Some(name) = display_name_from(&body) else {
|
|
476
|
-
return respond(
|
|
698
|
+
return respond(
|
|
699
|
+
400,
|
|
700
|
+
"application/json",
|
|
701
|
+
&json!({"error": "missing name (or first_name and last_name)"}).to_string(),
|
|
702
|
+
);
|
|
477
703
|
};
|
|
478
704
|
let Some(email) = body.get("email").and_then(|v| v.as_str()) else {
|
|
479
|
-
return respond(
|
|
705
|
+
return respond(
|
|
706
|
+
400,
|
|
707
|
+
"application/json",
|
|
708
|
+
&json!({"error": "missing email"}).to_string(),
|
|
709
|
+
);
|
|
480
710
|
};
|
|
481
711
|
|
|
482
712
|
let read = match dispatch::read(client, wasm_path).await {
|
|
483
713
|
Ok(r) => r,
|
|
484
714
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
485
715
|
};
|
|
486
|
-
let events = instances_for(
|
|
716
|
+
let events = instances_for(
|
|
717
|
+
&read,
|
|
718
|
+
&crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
|
|
719
|
+
);
|
|
487
720
|
let Some((_, event)) = events.iter().find(|(id, _)| id == event_slug) else {
|
|
488
|
-
return respond(
|
|
721
|
+
return respond(
|
|
722
|
+
404,
|
|
723
|
+
"application/json",
|
|
724
|
+
&json!({"error": "no such event"}).to_string(),
|
|
725
|
+
);
|
|
489
726
|
};
|
|
490
727
|
if event.get("status").and_then(|v| v.as_str()) != Some("open") {
|
|
491
|
-
return respond(
|
|
728
|
+
return respond(
|
|
729
|
+
422,
|
|
730
|
+
"application/json",
|
|
731
|
+
&json!({"error": "registration is closed for this event"}).to_string(),
|
|
732
|
+
);
|
|
492
733
|
}
|
|
493
734
|
// An event that opted into a cutoff stops taking registrations that many
|
|
494
735
|
// hours before it starts; a missing or unreadable field is no cutoff.
|
|
495
736
|
if registration_cutoff_passed(event, now_secs) {
|
|
496
|
-
return respond(
|
|
737
|
+
return respond(
|
|
738
|
+
422,
|
|
739
|
+
"application/json",
|
|
740
|
+
&json!({"error": "registration has closed for this event"}).to_string(),
|
|
741
|
+
);
|
|
497
742
|
}
|
|
498
743
|
// A full event refuses before any Stripe call or write. An event whose
|
|
499
744
|
// capacity cannot be read is not blocked: capacity is required when an
|
|
500
745
|
// event is scheduled, so its absence means nothing to enforce.
|
|
501
746
|
if seats_left(&read, &config.domain, payments, event_slug) == Some(0) {
|
|
502
|
-
return respond(
|
|
747
|
+
return respond(
|
|
748
|
+
409,
|
|
749
|
+
"application/json",
|
|
750
|
+
&json!({"error": "this event is full"}).to_string(),
|
|
751
|
+
);
|
|
503
752
|
}
|
|
504
|
-
let price_cents = event
|
|
505
|
-
|
|
753
|
+
let price_cents = event
|
|
754
|
+
.get("price")
|
|
755
|
+
.and_then(|p| p.get("cents"))
|
|
756
|
+
.and_then(serde_json::Value::as_i64)
|
|
757
|
+
.unwrap_or(0);
|
|
758
|
+
let event_name = event
|
|
759
|
+
.get("name")
|
|
760
|
+
.and_then(|n| n.get("value"))
|
|
761
|
+
.and_then(|v| v.as_str())
|
|
762
|
+
.unwrap_or("");
|
|
506
763
|
|
|
507
764
|
// What checkout does is decided per request from this tenant's own
|
|
508
765
|
// PaymentConnection, before anything is written — a paused connection
|
|
509
766
|
// must never fall back to the mock walkthrough.
|
|
510
|
-
let plan = payments::checkout_plan(
|
|
767
|
+
let plan = payments::checkout_plan(
|
|
768
|
+
payments::connection(&read, &config.domain).as_ref(),
|
|
769
|
+
platform,
|
|
770
|
+
);
|
|
511
771
|
let processor = match plan {
|
|
512
772
|
payments::CheckoutPlan::Mock => "mock_stripe",
|
|
513
773
|
payments::CheckoutPlan::Stripe { .. } => "stripe",
|
|
514
774
|
payments::CheckoutPlan::Paused => {
|
|
515
|
-
return respond(
|
|
775
|
+
return respond(
|
|
776
|
+
503,
|
|
777
|
+
"application/json",
|
|
778
|
+
&json!({"error": "payments are temporarily unavailable"}).to_string(),
|
|
779
|
+
);
|
|
516
780
|
}
|
|
517
781
|
};
|
|
518
782
|
let site_url = platform.site_url.as_str();
|
|
@@ -522,9 +786,27 @@ pub(crate) async fn registrations_route_at(
|
|
|
522
786
|
// A Stripe plan opens its embedded session before anything is written, so
|
|
523
787
|
// a failure leaves no Payment or Registration behind. If a domain refusal
|
|
524
788
|
// follows, the unused session simply expires.
|
|
525
|
-
let embedded_checkout = if let payments::CheckoutPlan::Stripe {
|
|
526
|
-
|
|
527
|
-
|
|
789
|
+
let embedded_checkout = if let payments::CheckoutPlan::Stripe {
|
|
790
|
+
api_key,
|
|
791
|
+
publishable_key,
|
|
792
|
+
} = &plan
|
|
793
|
+
{
|
|
794
|
+
let auth = checkout::StripeAuth {
|
|
795
|
+
api_key,
|
|
796
|
+
base_url: &platform.api_base,
|
|
797
|
+
};
|
|
798
|
+
match checkout::create_checkout_session(
|
|
799
|
+
&auth,
|
|
800
|
+
price_cents,
|
|
801
|
+
event_name,
|
|
802
|
+
&reference,
|
|
803
|
+
checkout::session_expires_at(
|
|
804
|
+
now_secs,
|
|
805
|
+
crate::commerce_ir::checkout_windows_binding().session_hold,
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
.await
|
|
809
|
+
{
|
|
528
810
|
// Stripe.js is opened with the publishable key; the answer carries
|
|
529
811
|
// no `checkout_url`.
|
|
530
812
|
Ok(session) => Some(json!({
|
|
@@ -533,8 +815,14 @@ pub(crate) async fn registrations_route_at(
|
|
|
533
815
|
"session_id": session.session_id,
|
|
534
816
|
})),
|
|
535
817
|
Err(e) => {
|
|
536
|
-
eprintln!(
|
|
537
|
-
|
|
818
|
+
eprintln!(
|
|
819
|
+
"registrations: opening the Stripe session failed, nothing was recorded: {e:#}"
|
|
820
|
+
);
|
|
821
|
+
return respond(
|
|
822
|
+
502,
|
|
823
|
+
"application/json",
|
|
824
|
+
&json!({"error": "payments are temporarily unavailable"}).to_string(),
|
|
825
|
+
);
|
|
538
826
|
}
|
|
539
827
|
}
|
|
540
828
|
} else {
|
|
@@ -548,12 +836,16 @@ pub(crate) async fn registrations_route_at(
|
|
|
548
836
|
"amount": {"cents": price_cents},
|
|
549
837
|
"client": {"name": name, "email": email},
|
|
550
838
|
});
|
|
551
|
-
let outcome = match dispatch::handle(
|
|
839
|
+
let outcome = match dispatch::handle(host, &payments.initiate, initiate_args, None).await {
|
|
552
840
|
Ok(o) => o,
|
|
553
841
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
554
842
|
};
|
|
555
843
|
if !outcome.accepted {
|
|
556
|
-
return respond(
|
|
844
|
+
return respond(
|
|
845
|
+
422,
|
|
846
|
+
"application/json",
|
|
847
|
+
&last_refusal(&outcome.result).to_string(),
|
|
848
|
+
);
|
|
557
849
|
}
|
|
558
850
|
|
|
559
851
|
let mut request_args = json!({
|
|
@@ -564,10 +856,17 @@ pub(crate) async fn registrations_route_at(
|
|
|
564
856
|
// A reaction reads only fields its event carries, never ones nested
|
|
565
857
|
// inside `attendee`, so newsletter fields are declared flat on
|
|
566
858
|
// Registration.Request; a command refuses args it doesn't declare.
|
|
567
|
-
let news_signup = body
|
|
859
|
+
let news_signup = body
|
|
860
|
+
.get("news_signup")
|
|
861
|
+
.and_then(serde_json::Value::as_bool)
|
|
862
|
+
.unwrap_or(false);
|
|
568
863
|
let binding = crate::commerce_ir::registrations_binding(&config.domain);
|
|
569
864
|
let forwards_newsletter = crate::ir::ir().is_some_and(|ir| {
|
|
570
|
-
binding
|
|
865
|
+
binding
|
|
866
|
+
.request_target()
|
|
867
|
+
.is_some_and(|(aggregate, command)| {
|
|
868
|
+
crate::commerce_ir::command_declares(ir, aggregate, command, "news_signup")
|
|
869
|
+
})
|
|
571
870
|
});
|
|
572
871
|
if forwards_newsletter {
|
|
573
872
|
request_args["news_signup"] = json!(news_signup);
|
|
@@ -579,12 +878,16 @@ pub(crate) async fn registrations_route_at(
|
|
|
579
878
|
}
|
|
580
879
|
}
|
|
581
880
|
let request_verb = crate::commerce_ir::registrations_binding(&config.domain).request;
|
|
582
|
-
let outcome = match dispatch::handle(
|
|
881
|
+
let outcome = match dispatch::handle(host, &request_verb, request_args, None).await {
|
|
583
882
|
Ok(o) => o,
|
|
584
883
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
585
884
|
};
|
|
586
885
|
if !outcome.accepted {
|
|
587
|
-
return respond(
|
|
886
|
+
return respond(
|
|
887
|
+
422,
|
|
888
|
+
"application/json",
|
|
889
|
+
&last_refusal(&outcome.result).to_string(),
|
|
890
|
+
);
|
|
588
891
|
}
|
|
589
892
|
|
|
590
893
|
// The domain's reaction has already subscribed the address by now; a
|
|
@@ -595,7 +898,11 @@ pub(crate) async fn registrations_route_at(
|
|
|
595
898
|
}
|
|
596
899
|
|
|
597
900
|
if let Some(embedded) = embedded_checkout {
|
|
598
|
-
return respond(
|
|
901
|
+
return respond(
|
|
902
|
+
200,
|
|
903
|
+
"application/json",
|
|
904
|
+
&json!({"registration_id": reference, "embedded_checkout": embedded}).to_string(),
|
|
905
|
+
);
|
|
599
906
|
}
|
|
600
907
|
|
|
601
908
|
// `return_to` is guest-supplied and never trusted raw — only a same-site
|
|
@@ -606,21 +913,35 @@ pub(crate) async fn registrations_route_at(
|
|
|
606
913
|
_ => "/".to_string(),
|
|
607
914
|
};
|
|
608
915
|
let confirm_query = |outcome: &str| -> String {
|
|
609
|
-
|
|
916
|
+
// The base is only a carrier for the query encoder; it always parses.
|
|
917
|
+
let Ok(mut url) = reqwest::Url::parse("http://placeholder.invalid/") else {
|
|
918
|
+
return String::new();
|
|
919
|
+
};
|
|
610
920
|
url.query_pairs_mut()
|
|
611
921
|
.append_pair("registration_id", &reference)
|
|
612
922
|
.append_pair("outcome", outcome)
|
|
613
923
|
.append_pair("return_to", &return_to);
|
|
614
924
|
url.query().unwrap_or("").to_string()
|
|
615
925
|
};
|
|
616
|
-
let success_url = format!(
|
|
617
|
-
|
|
926
|
+
let success_url = format!(
|
|
927
|
+
"{site_url}/registration-confirmed.html?{}",
|
|
928
|
+
confirm_query("succeeded")
|
|
929
|
+
);
|
|
930
|
+
let cancel_url = format!(
|
|
931
|
+
"{site_url}/registration-confirmed.html?{}",
|
|
932
|
+
confirm_query("cancelled")
|
|
933
|
+
);
|
|
618
934
|
|
|
619
935
|
// A tenant with no connection, or a disabled one, is on the mock
|
|
620
936
|
// walkthrough, never a misconfiguration to refuse. success_url/cancel_url
|
|
621
937
|
// belong to this walkthrough only — an embedded session redirects nowhere.
|
|
622
|
-
let checkout_url =
|
|
623
|
-
|
|
938
|
+
let checkout_url =
|
|
939
|
+
checkout::mock_checkout_session(&reference, &success_url, &cancel_url, site_url);
|
|
940
|
+
respond(
|
|
941
|
+
200,
|
|
942
|
+
"application/json",
|
|
943
|
+
&json!({"checkout_url": checkout_url, "registration_id": reference}).to_string(),
|
|
944
|
+
)
|
|
624
945
|
}
|
|
625
946
|
|
|
626
947
|
// The answer for an event that only the public mock secret could vouch for.
|
|
@@ -642,13 +963,13 @@ pub(crate) async fn webhook_route(
|
|
|
642
963
|
raw_body: &str,
|
|
643
964
|
signature_header: &str,
|
|
644
965
|
platform: &payments::PlatformConfig,
|
|
645
|
-
|
|
646
|
-
wasm_path: &Path,
|
|
647
|
-
config: &LineageConfig,
|
|
648
|
-
invoker: &dyn LambdaInvoker,
|
|
966
|
+
host: Host<'_>,
|
|
649
967
|
payments: &PaymentsProvider,
|
|
650
968
|
) -> Value {
|
|
651
|
-
let
|
|
969
|
+
let Host {
|
|
970
|
+
client, wasm_path, ..
|
|
971
|
+
} = host;
|
|
972
|
+
let now = unix_now();
|
|
652
973
|
// The environment's signing secret and any saved with the business's own
|
|
653
974
|
// keys are all accepted; with none configured only the mock secret is.
|
|
654
975
|
let configured = platform.webhook_secrets();
|
|
@@ -657,11 +978,16 @@ pub(crate) async fn webhook_route(
|
|
|
657
978
|
// mock secret must not stand in for it.
|
|
658
979
|
return mock_secret_refused();
|
|
659
980
|
}
|
|
660
|
-
let candidates = if configured.is_empty() {
|
|
981
|
+
let candidates = if configured.is_empty() {
|
|
982
|
+
vec![MOCK_STRIPE_WEBHOOK_SECRET]
|
|
983
|
+
} else {
|
|
984
|
+
configured
|
|
985
|
+
};
|
|
661
986
|
let tolerance = crate::commerce_ir::checkout_windows_binding().webhook_tolerance;
|
|
662
987
|
let mut verification = Ok(());
|
|
663
988
|
for secret in &candidates {
|
|
664
|
-
verification =
|
|
989
|
+
verification =
|
|
990
|
+
checkout::verify_signature(raw_body, signature_header, secret, now, tolerance);
|
|
665
991
|
if verification.is_ok() {
|
|
666
992
|
break;
|
|
667
993
|
}
|
|
@@ -687,12 +1013,21 @@ pub(crate) async fn webhook_route(
|
|
|
687
1013
|
let refuse_fallback = mock_secret_refused;
|
|
688
1014
|
|
|
689
1015
|
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
690
|
-
let object = event
|
|
691
|
-
|
|
1016
|
+
let object = event
|
|
1017
|
+
.get("data")
|
|
1018
|
+
.and_then(|d| d.get("object"))
|
|
1019
|
+
.cloned()
|
|
1020
|
+
.unwrap_or_else(|| json!({}));
|
|
1021
|
+
let reference = object
|
|
1022
|
+
.get("metadata")
|
|
1023
|
+
.and_then(|m| m.get("registration_id"))
|
|
1024
|
+
.and_then(|v| v.as_str())
|
|
1025
|
+
.map(String::from);
|
|
692
1026
|
|
|
693
1027
|
if let Some(reference) = reference {
|
|
694
1028
|
// Defaults to "stripe" when there's no matching Payment.
|
|
695
|
-
let processor =
|
|
1029
|
+
let processor =
|
|
1030
|
+
payment_processor(&read, payments, &reference).unwrap_or_else(|| "stripe".to_string());
|
|
696
1031
|
if fallback_secret && processor != "mock_stripe" {
|
|
697
1032
|
return refuse_fallback();
|
|
698
1033
|
}
|
|
@@ -707,21 +1042,29 @@ pub(crate) async fn webhook_route(
|
|
|
707
1042
|
// The PaymentIntent id when one exists, else the Checkout
|
|
708
1043
|
// Session's own id — `.get`, not a panic, since a synthetic
|
|
709
1044
|
// test payload carries no payment_intent at all.
|
|
710
|
-
let transaction_id = object
|
|
1045
|
+
let transaction_id = object
|
|
1046
|
+
.get("payment_intent")
|
|
1047
|
+
.and_then(|v| v.as_str())
|
|
711
1048
|
.or_else(|| object.get("id").and_then(|v| v.as_str()))
|
|
712
1049
|
.unwrap_or("")
|
|
713
1050
|
.to_string();
|
|
714
|
-
Some((
|
|
1051
|
+
Some((
|
|
1052
|
+
payments.succeeded.as_str(),
|
|
1053
|
+
json!({
|
|
1054
|
+
"reference": reference_fact,
|
|
1055
|
+
"transaction_id": {"value": transaction_id},
|
|
1056
|
+
"reported_processor": reported_processor,
|
|
1057
|
+
}),
|
|
1058
|
+
))
|
|
1059
|
+
}
|
|
1060
|
+
"checkout.session.expired" => Some((
|
|
1061
|
+
payments.failed.as_str(),
|
|
1062
|
+
json!({
|
|
715
1063
|
"reference": reference_fact,
|
|
716
|
-
"
|
|
1064
|
+
"reason": {"value": payments.lapse_reason},
|
|
717
1065
|
"reported_processor": reported_processor,
|
|
718
|
-
})
|
|
719
|
-
|
|
720
|
-
"checkout.session.expired" => Some((payments.failed.as_str(), json!({
|
|
721
|
-
"reference": reference_fact,
|
|
722
|
-
"reason": {"value": payments.lapse_reason},
|
|
723
|
-
"reported_processor": reported_processor,
|
|
724
|
-
}))),
|
|
1066
|
+
}),
|
|
1067
|
+
)),
|
|
725
1068
|
_ => None,
|
|
726
1069
|
};
|
|
727
1070
|
|
|
@@ -729,12 +1072,12 @@ pub(crate) async fn webhook_route(
|
|
|
729
1072
|
// A refusal here (e.g. a redelivered webhook — Stripe's delivery
|
|
730
1073
|
// is at-least-once) is a benign no-op: the payment already holds
|
|
731
1074
|
// the right status, and 200 tells Stripe's retry logic to stop.
|
|
732
|
-
match dispatch::handle_routed(
|
|
1075
|
+
match dispatch::handle_routed(host, verb, json!(reference), facts, None).await {
|
|
733
1076
|
Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
|
|
734
1077
|
// Only the delivery that settles the payment sends the receipt;
|
|
735
1078
|
// a redelivery is refused and stays quiet.
|
|
736
1079
|
Ok(settled) if settled.accepted && event_type == "checkout.session.completed" => {
|
|
737
|
-
super::registration_receipt::
|
|
1080
|
+
super::registration_receipt::deliver_receipt(&reference, host, payments).await;
|
|
738
1081
|
}
|
|
739
1082
|
Ok(_) => {}
|
|
740
1083
|
}
|
|
@@ -751,34 +1094,65 @@ mod tests {
|
|
|
751
1094
|
use crate::web::tests::{provision_lineage, scratch_db};
|
|
752
1095
|
|
|
753
1096
|
fn checkout_config(era: i32) -> LineageConfig {
|
|
754
|
-
LineageConfig {
|
|
1097
|
+
LineageConfig {
|
|
1098
|
+
domain: "CheckoutFixture".to_string(),
|
|
1099
|
+
era: Some(era),
|
|
1100
|
+
mirrored: None,
|
|
1101
|
+
}
|
|
755
1102
|
}
|
|
756
1103
|
|
|
757
1104
|
fn checkout_wasm_path() -> std::path::PathBuf {
|
|
758
1105
|
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm")
|
|
759
1106
|
}
|
|
760
1107
|
|
|
761
|
-
async fn schedule_event(
|
|
1108
|
+
async fn schedule_event(
|
|
1109
|
+
client: &Mutex<Client>,
|
|
1110
|
+
wasm_path: &Path,
|
|
1111
|
+
config: &LineageConfig,
|
|
1112
|
+
slug: &str,
|
|
1113
|
+
price_cents: i64,
|
|
1114
|
+
) {
|
|
762
1115
|
let args = json!({
|
|
763
1116
|
"slug": {"value": slug}, "name": {"value": "Sample Studio"},
|
|
764
1117
|
"price": {"cents": price_cents}, "capacity": {"value": 20},
|
|
765
1118
|
});
|
|
766
|
-
let outcome = dispatch::handle(
|
|
767
|
-
|
|
768
|
-
.
|
|
769
|
-
|
|
1119
|
+
let outcome = dispatch::handle(
|
|
1120
|
+
Host::new(client, wasm_path, config, &lambda_client::NeverInvoker),
|
|
1121
|
+
"CheckoutFixture::Event.Schedule",
|
|
1122
|
+
args,
|
|
1123
|
+
None,
|
|
1124
|
+
)
|
|
1125
|
+
.await
|
|
1126
|
+
.unwrap();
|
|
1127
|
+
assert!(
|
|
1128
|
+
outcome.accepted,
|
|
1129
|
+
"scheduling the fixture event should succeed: {:?}",
|
|
1130
|
+
outcome.result
|
|
1131
|
+
);
|
|
770
1132
|
}
|
|
771
1133
|
|
|
772
1134
|
fn sign_stripe_header(secret: &str, now: i64, payload: &str) -> String {
|
|
773
1135
|
use hmac::{Hmac, Mac};
|
|
774
1136
|
use sha2::Sha256;
|
|
1137
|
+
use std::fmt::Write;
|
|
775
1138
|
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
|
776
1139
|
mac.update(format!("{now}.{payload}").as_bytes());
|
|
777
|
-
|
|
1140
|
+
let digest = mac
|
|
1141
|
+
.finalize()
|
|
1142
|
+
.into_bytes()
|
|
1143
|
+
.iter()
|
|
1144
|
+
.fold(String::new(), |mut hex, b| {
|
|
1145
|
+
let _ = write!(hex, "{b:02x}"); // writing to a String cannot fail
|
|
1146
|
+
hex
|
|
1147
|
+
});
|
|
1148
|
+
format!("t={now},v1={digest}")
|
|
778
1149
|
}
|
|
779
1150
|
|
|
780
1151
|
fn now_secs() -> i64 {
|
|
781
|
-
std::time::SystemTime::now()
|
|
1152
|
+
std::time::SystemTime::now()
|
|
1153
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1154
|
+
.unwrap()
|
|
1155
|
+
.as_secs() as i64
|
|
782
1156
|
}
|
|
783
1157
|
|
|
784
1158
|
// A read of the domain: one `yoga` event of `capacity` seats, one `other`
|
|
@@ -786,11 +1160,23 @@ mod tests {
|
|
|
786
1160
|
// (reference, event slug, payment status).
|
|
787
1161
|
fn read_with(capacity: i64, rows: &[(&str, &str, &str)]) -> Value {
|
|
788
1162
|
let mut instances = serde_json::Map::new();
|
|
789
|
-
instances.insert(
|
|
790
|
-
|
|
1163
|
+
instances.insert(
|
|
1164
|
+
"CheckoutFixture::Event#yoga".into(),
|
|
1165
|
+
json!({"capacity": {"value": capacity}}),
|
|
1166
|
+
);
|
|
1167
|
+
instances.insert(
|
|
1168
|
+
"CheckoutFixture::Event#other".into(),
|
|
1169
|
+
json!({"capacity": {"value": 5}}),
|
|
1170
|
+
);
|
|
791
1171
|
for (reference, event, status) in rows {
|
|
792
|
-
instances.insert(
|
|
793
|
-
|
|
1172
|
+
instances.insert(
|
|
1173
|
+
format!("CheckoutFixture::Registration#{reference}"),
|
|
1174
|
+
json!({"event_slug": event}),
|
|
1175
|
+
);
|
|
1176
|
+
instances.insert(
|
|
1177
|
+
format!("Payments::Payment#{reference}"),
|
|
1178
|
+
json!({"status": status}),
|
|
1179
|
+
);
|
|
794
1180
|
}
|
|
795
1181
|
json!({"instances": instances})
|
|
796
1182
|
}
|
|
@@ -800,10 +1186,18 @@ mod tests {
|
|
|
800
1186
|
let payments = four_state_payments();
|
|
801
1187
|
let read = read_with(
|
|
802
1188
|
10,
|
|
803
|
-
&[
|
|
1189
|
+
&[
|
|
1190
|
+
("a", "yoga", "pending"),
|
|
1191
|
+
("b", "yoga", "succeeded"),
|
|
1192
|
+
("c", "yoga", "refunding"),
|
|
1193
|
+
("d", "yoga", "disputed"),
|
|
1194
|
+
],
|
|
804
1195
|
);
|
|
805
1196
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 4);
|
|
806
|
-
assert_eq!(
|
|
1197
|
+
assert_eq!(
|
|
1198
|
+
seats_left(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1199
|
+
Some(6)
|
|
1200
|
+
);
|
|
807
1201
|
}
|
|
808
1202
|
|
|
809
1203
|
#[test]
|
|
@@ -811,34 +1205,67 @@ mod tests {
|
|
|
811
1205
|
let payments = crate::commerce_ir::fixture_payments();
|
|
812
1206
|
let read = read_with(
|
|
813
1207
|
3,
|
|
814
|
-
&[
|
|
1208
|
+
&[
|
|
1209
|
+
("a", "yoga", "failed"),
|
|
1210
|
+
("b", "yoga", "refunded"),
|
|
1211
|
+
("c", "yoga", "charged_back"),
|
|
1212
|
+
("d", "yoga", "succeeded"),
|
|
1213
|
+
],
|
|
815
1214
|
);
|
|
816
1215
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1);
|
|
817
|
-
assert_eq!(
|
|
1216
|
+
assert_eq!(
|
|
1217
|
+
seats_left(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1218
|
+
Some(2)
|
|
1219
|
+
);
|
|
818
1220
|
}
|
|
819
1221
|
|
|
820
1222
|
#[test]
|
|
821
1223
|
fn seats_are_counted_per_event_and_a_registration_without_a_payment_holds_none() {
|
|
822
1224
|
let payments = crate::commerce_ir::fixture_payments();
|
|
823
|
-
let mut read = read_with(
|
|
1225
|
+
let mut read = read_with(
|
|
1226
|
+
4,
|
|
1227
|
+
&[("a", "yoga", "succeeded"), ("b", "other", "succeeded")],
|
|
1228
|
+
);
|
|
824
1229
|
read["instances"]["CheckoutFixture::Registration#orphan"] = json!({"event_slug": "yoga"});
|
|
825
|
-
assert_eq!(
|
|
1230
|
+
assert_eq!(
|
|
1231
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1232
|
+
1,
|
|
1233
|
+
"only this event's registrations, and only those with a payment"
|
|
1234
|
+
);
|
|
826
1235
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
|
|
827
1236
|
}
|
|
828
1237
|
|
|
829
1238
|
#[test]
|
|
830
1239
|
fn an_archived_registration_gives_its_seat_back_whatever_its_payment_says() {
|
|
831
1240
|
let payments = crate::commerce_ir::fixture_payments();
|
|
832
|
-
let mut read = read_with(
|
|
1241
|
+
let mut read = read_with(
|
|
1242
|
+
3,
|
|
1243
|
+
&[
|
|
1244
|
+
("a", "yoga", "succeeded"),
|
|
1245
|
+
("b", "yoga", "pending"),
|
|
1246
|
+
("c", "yoga", "succeeded"),
|
|
1247
|
+
],
|
|
1248
|
+
);
|
|
833
1249
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 3);
|
|
834
1250
|
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
|
|
835
|
-
assert_eq!(
|
|
836
|
-
|
|
1251
|
+
assert_eq!(
|
|
1252
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1253
|
+
2,
|
|
1254
|
+
"a paid but archived registration frees its seat"
|
|
1255
|
+
);
|
|
1256
|
+
assert_eq!(
|
|
1257
|
+
seats_left(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1258
|
+
Some(1)
|
|
1259
|
+
);
|
|
837
1260
|
}
|
|
838
1261
|
|
|
839
1262
|
fn binding_freeing(states: &[&str]) -> crate::commerce_ir::RegistrationsProvider {
|
|
840
|
-
let mut binding =
|
|
841
|
-
|
|
1263
|
+
let mut binding =
|
|
1264
|
+
crate::commerce_ir::RegistrationsProvider::conventional("CheckoutFixture");
|
|
1265
|
+
binding.frees_seat = states
|
|
1266
|
+
.iter()
|
|
1267
|
+
.map(std::string::ToString::to_string)
|
|
1268
|
+
.collect();
|
|
842
1269
|
binding
|
|
843
1270
|
}
|
|
844
1271
|
|
|
@@ -848,17 +1275,32 @@ mod tests {
|
|
|
848
1275
|
let mut read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
|
|
849
1276
|
set_registration_status(&mut read, "a", "cancelled");
|
|
850
1277
|
let counts = count_seats(&read, &binding_freeing(&["cancelled"]), &payments);
|
|
851
|
-
assert_eq!(
|
|
1278
|
+
assert_eq!(
|
|
1279
|
+
counts.get("yoga"),
|
|
1280
|
+
Some(&1),
|
|
1281
|
+
"cancelled is declared, so it frees the seat"
|
|
1282
|
+
);
|
|
852
1283
|
}
|
|
853
1284
|
|
|
854
1285
|
#[test]
|
|
855
1286
|
fn the_fixture_declared_cancelled_state_frees_its_seat_beside_archived() {
|
|
856
1287
|
let payments = crate::commerce_ir::fixture_payments();
|
|
857
|
-
let binding = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir())
|
|
858
|
-
|
|
1288
|
+
let binding = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir())
|
|
1289
|
+
.expect("registrations");
|
|
1290
|
+
let mut read = read_with(
|
|
1291
|
+
3,
|
|
1292
|
+
&[
|
|
1293
|
+
("a", "yoga", "succeeded"),
|
|
1294
|
+
("b", "yoga", "succeeded"),
|
|
1295
|
+
("c", "yoga", "succeeded"),
|
|
1296
|
+
],
|
|
1297
|
+
);
|
|
859
1298
|
set_registration_status(&mut read, "a", "cancelled");
|
|
860
1299
|
set_registration_status(&mut read, "b", "archived");
|
|
861
|
-
assert_eq!(
|
|
1300
|
+
assert_eq!(
|
|
1301
|
+
count_seats(&read, &binding, &payments).get("yoga"),
|
|
1302
|
+
Some(&1)
|
|
1303
|
+
);
|
|
862
1304
|
}
|
|
863
1305
|
|
|
864
1306
|
#[test]
|
|
@@ -867,7 +1309,11 @@ mod tests {
|
|
|
867
1309
|
let mut read = read_with(3, &[("a", "yoga", "succeeded")]);
|
|
868
1310
|
set_registration_status(&mut read, "a", "archived");
|
|
869
1311
|
let counts = count_seats(&read, &binding_freeing(&["cancelled"]), &payments);
|
|
870
|
-
assert_eq!(
|
|
1312
|
+
assert_eq!(
|
|
1313
|
+
counts.get("yoga"),
|
|
1314
|
+
Some(&1),
|
|
1315
|
+
"only the declared states free a seat"
|
|
1316
|
+
);
|
|
871
1317
|
}
|
|
872
1318
|
|
|
873
1319
|
#[test]
|
|
@@ -875,8 +1321,12 @@ mod tests {
|
|
|
875
1321
|
let payments = crate::commerce_ir::fixture_payments();
|
|
876
1322
|
let mut read = read_with(3, &[("a", "yoga", "succeeded")]);
|
|
877
1323
|
set_registration_status(&mut read, "a", "archived");
|
|
878
|
-
let conventional =
|
|
879
|
-
|
|
1324
|
+
let conventional =
|
|
1325
|
+
crate::commerce_ir::RegistrationsProvider::conventional("CheckoutFixture");
|
|
1326
|
+
assert_eq!(
|
|
1327
|
+
count_seats(&read, &conventional, &payments).get("yoga"),
|
|
1328
|
+
None
|
|
1329
|
+
);
|
|
880
1330
|
}
|
|
881
1331
|
|
|
882
1332
|
#[test]
|
|
@@ -888,7 +1338,11 @@ mod tests {
|
|
|
888
1338
|
read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
|
|
889
1339
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
890
1340
|
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!(null);
|
|
891
|
-
assert_eq!(
|
|
1341
|
+
assert_eq!(
|
|
1342
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1343
|
+
2,
|
|
1344
|
+
"a null status is not archived either"
|
|
1345
|
+
);
|
|
892
1346
|
}
|
|
893
1347
|
|
|
894
1348
|
#[test]
|
|
@@ -896,9 +1350,15 @@ mod tests {
|
|
|
896
1350
|
let payments = crate::commerce_ir::fixture_payments();
|
|
897
1351
|
let mut read = read_with(2, &[("a", "yoga", "succeeded")]);
|
|
898
1352
|
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
|
|
899
|
-
assert_eq!(
|
|
1353
|
+
assert_eq!(
|
|
1354
|
+
seats_left(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1355
|
+
Some(2)
|
|
1356
|
+
);
|
|
900
1357
|
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("active");
|
|
901
|
-
assert_eq!(
|
|
1358
|
+
assert_eq!(
|
|
1359
|
+
seats_left(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1360
|
+
Some(1)
|
|
1361
|
+
);
|
|
902
1362
|
}
|
|
903
1363
|
|
|
904
1364
|
#[test]
|
|
@@ -906,7 +1366,13 @@ mod tests {
|
|
|
906
1366
|
let payments = crate::commerce_ir::fixture_payments();
|
|
907
1367
|
let mut read = read_with(
|
|
908
1368
|
6,
|
|
909
|
-
&[
|
|
1369
|
+
&[
|
|
1370
|
+
("a", "yoga", "succeeded"),
|
|
1371
|
+
("b", "yoga", "succeeded"),
|
|
1372
|
+
("c", "yoga", "pending"),
|
|
1373
|
+
("d", "yoga", "failed"),
|
|
1374
|
+
("e", "other", "succeeded"),
|
|
1375
|
+
],
|
|
910
1376
|
);
|
|
911
1377
|
read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
|
|
912
1378
|
read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
|
|
@@ -915,18 +1381,35 @@ mod tests {
|
|
|
915
1381
|
// b (active, paid) and c (no status, pending) hold; a is archived, d's
|
|
916
1382
|
// payment failed, and e is another event's.
|
|
917
1383
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
918
|
-
assert_eq!(
|
|
919
|
-
|
|
1384
|
+
assert_eq!(
|
|
1385
|
+
seats_left(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1386
|
+
Some(4)
|
|
1387
|
+
);
|
|
1388
|
+
assert_eq!(
|
|
1389
|
+
seats_left(&read, "CheckoutFixture", &payments, "other"),
|
|
1390
|
+
Some(5),
|
|
1391
|
+
"the archived registration on `other` frees its seat"
|
|
1392
|
+
);
|
|
920
1393
|
}
|
|
921
1394
|
|
|
922
1395
|
#[test]
|
|
923
1396
|
fn seats_left_never_goes_below_zero_and_is_none_without_an_event_or_capacity() {
|
|
924
1397
|
let payments = crate::commerce_ir::fixture_payments();
|
|
925
1398
|
let oversold = read_with(1, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
|
|
926
|
-
assert_eq!(
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1399
|
+
assert_eq!(
|
|
1400
|
+
seats_left(&oversold, "CheckoutFixture", &payments, "yoga"),
|
|
1401
|
+
Some(0)
|
|
1402
|
+
);
|
|
1403
|
+
assert_eq!(
|
|
1404
|
+
seats_left(&oversold, "CheckoutFixture", &payments, "nowhere"),
|
|
1405
|
+
None
|
|
1406
|
+
);
|
|
1407
|
+
let no_capacity =
|
|
1408
|
+
json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
|
|
1409
|
+
assert_eq!(
|
|
1410
|
+
seats_left(&no_capacity, "CheckoutFixture", &payments, "yoga"),
|
|
1411
|
+
None
|
|
1412
|
+
);
|
|
930
1413
|
}
|
|
931
1414
|
|
|
932
1415
|
// The site's own JavaScript tests of this rule (its seat-count spec) use the
|
|
@@ -935,20 +1418,27 @@ mod tests {
|
|
|
935
1418
|
const FREEING: [&str; 3] = ["failed", "refunded", "charged_back"];
|
|
936
1419
|
|
|
937
1420
|
fn set_registration_status(read: &mut Value, reference: &str, status: &str) {
|
|
938
|
-
read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"] =
|
|
1421
|
+
read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"] =
|
|
1422
|
+
json!(status);
|
|
939
1423
|
}
|
|
940
1424
|
|
|
941
1425
|
/// The fixture's payments binding with the production table: the fixture lifecycle has only
|
|
942
1426
|
/// pending, succeeded and failed, so refunding and disputed need the table set here.
|
|
943
1427
|
fn four_state_payments() -> crate::commerce_ir::PaymentsProvider {
|
|
944
1428
|
let mut payments = crate::commerce_ir::fixture_payments();
|
|
945
|
-
payments.holds_seat = HOLDING
|
|
1429
|
+
payments.holds_seat = HOLDING
|
|
1430
|
+
.iter()
|
|
1431
|
+
.map(std::string::ToString::to_string)
|
|
1432
|
+
.collect();
|
|
946
1433
|
payments
|
|
947
1434
|
}
|
|
948
1435
|
|
|
949
1436
|
#[test]
|
|
950
1437
|
fn the_seat_holding_table_comes_from_the_fixture_ir_mark() {
|
|
951
|
-
assert_eq!(
|
|
1438
|
+
assert_eq!(
|
|
1439
|
+
crate::commerce_ir::fixture_payments().holds_seat,
|
|
1440
|
+
vec!["pending", "succeeded"]
|
|
1441
|
+
);
|
|
952
1442
|
}
|
|
953
1443
|
|
|
954
1444
|
#[test]
|
|
@@ -956,11 +1446,19 @@ mod tests {
|
|
|
956
1446
|
let payments = four_state_payments();
|
|
957
1447
|
for status in HOLDING {
|
|
958
1448
|
let read = read_with(10, &[("r", "yoga", status)]);
|
|
959
|
-
assert_eq!(
|
|
1449
|
+
assert_eq!(
|
|
1450
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1451
|
+
1,
|
|
1452
|
+
"{status} should hold a seat"
|
|
1453
|
+
);
|
|
960
1454
|
}
|
|
961
1455
|
for status in FREEING {
|
|
962
1456
|
let read = read_with(10, &[("r", "yoga", status)]);
|
|
963
|
-
assert_eq!(
|
|
1457
|
+
assert_eq!(
|
|
1458
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1459
|
+
0,
|
|
1460
|
+
"{status} should free the seat"
|
|
1461
|
+
);
|
|
964
1462
|
}
|
|
965
1463
|
}
|
|
966
1464
|
|
|
@@ -969,13 +1467,23 @@ mod tests {
|
|
|
969
1467
|
let payments = crate::commerce_ir::fixture_payments();
|
|
970
1468
|
let mut read = read_with(
|
|
971
1469
|
10,
|
|
972
|
-
&[
|
|
1470
|
+
&[
|
|
1471
|
+
("a", "yoga", "succeeded"),
|
|
1472
|
+
("b", "yoga", "pending"),
|
|
1473
|
+
("c", "other", "pending"),
|
|
1474
|
+
("d", "yoga", "failed"),
|
|
1475
|
+
],
|
|
973
1476
|
);
|
|
974
|
-
read["instances"]["CheckoutFixture::Registration#b"]["event_slug"] =
|
|
1477
|
+
read["instances"]["CheckoutFixture::Registration#b"]["event_slug"] =
|
|
1478
|
+
json!({"value": "yoga"});
|
|
975
1479
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
|
|
976
1480
|
assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
|
|
977
1481
|
read["instances"]["CheckoutFixture::Registration#a"]["event_slug"] = json!("");
|
|
978
|
-
assert_eq!(
|
|
1482
|
+
assert_eq!(
|
|
1483
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1484
|
+
1,
|
|
1485
|
+
"an empty event id is no reference"
|
|
1486
|
+
);
|
|
979
1487
|
}
|
|
980
1488
|
|
|
981
1489
|
#[test]
|
|
@@ -997,14 +1505,25 @@ mod tests {
|
|
|
997
1505
|
for status in HOLDING {
|
|
998
1506
|
let mut read = read_with(10, &[("r", "yoga", status)]);
|
|
999
1507
|
set_registration_status(&mut read, "r", "archived");
|
|
1000
|
-
assert_eq!(
|
|
1508
|
+
assert_eq!(
|
|
1509
|
+
seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1510
|
+
0,
|
|
1511
|
+
"archived + {status} should not hold a seat"
|
|
1512
|
+
);
|
|
1001
1513
|
}
|
|
1002
1514
|
}
|
|
1003
1515
|
|
|
1004
1516
|
#[test]
|
|
1005
1517
|
fn archiving_one_of_several_guests_frees_exactly_one_seat() {
|
|
1006
1518
|
let payments = crate::commerce_ir::fixture_payments();
|
|
1007
|
-
let mut read = read_with(
|
|
1519
|
+
let mut read = read_with(
|
|
1520
|
+
10,
|
|
1521
|
+
&[
|
|
1522
|
+
("a", "yoga", "succeeded"),
|
|
1523
|
+
("b", "yoga", "succeeded"),
|
|
1524
|
+
("c", "yoga", "pending"),
|
|
1525
|
+
],
|
|
1526
|
+
);
|
|
1008
1527
|
set_registration_status(&mut read, "a", "active");
|
|
1009
1528
|
set_registration_status(&mut read, "b", "archived");
|
|
1010
1529
|
set_registration_status(&mut read, "c", "active");
|
|
@@ -1014,9 +1533,23 @@ mod tests {
|
|
|
1014
1533
|
#[test]
|
|
1015
1534
|
fn seat_figures_carry_capacity_taken_and_left_and_null_without_a_capacity() {
|
|
1016
1535
|
let payments = crate::commerce_ir::fixture_payments();
|
|
1017
|
-
let read = read_with(
|
|
1018
|
-
|
|
1019
|
-
|
|
1536
|
+
let read = read_with(
|
|
1537
|
+
3,
|
|
1538
|
+
&[
|
|
1539
|
+
("a", "yoga", "succeeded"),
|
|
1540
|
+
("b", "yoga", "pending"),
|
|
1541
|
+
("c", "yoga", "succeeded"),
|
|
1542
|
+
("d", "yoga", "succeeded"),
|
|
1543
|
+
],
|
|
1544
|
+
);
|
|
1545
|
+
assert_eq!(
|
|
1546
|
+
one_seat_figures(&read, "CheckoutFixture", &payments, "yoga"),
|
|
1547
|
+
Some(json!({"slug": "yoga", "capacity": 3, "seats_taken": 4, "seats_left": 0}))
|
|
1548
|
+
);
|
|
1549
|
+
assert_eq!(
|
|
1550
|
+
one_seat_figures(&read, "CheckoutFixture", &payments, "nowhere"),
|
|
1551
|
+
None
|
|
1552
|
+
);
|
|
1020
1553
|
assert_eq!(
|
|
1021
1554
|
all_seat_figures(&read, "CheckoutFixture", &payments),
|
|
1022
1555
|
json!({"events": {
|
|
@@ -1024,7 +1557,8 @@ mod tests {
|
|
|
1024
1557
|
"other": {"capacity": 5, "seats_taken": 0, "seats_left": 5},
|
|
1025
1558
|
}})
|
|
1026
1559
|
);
|
|
1027
|
-
let no_capacity =
|
|
1560
|
+
let no_capacity =
|
|
1561
|
+
json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
|
|
1028
1562
|
assert_eq!(
|
|
1029
1563
|
one_seat_figures(&no_capacity, "CheckoutFixture", &payments, "yoga"),
|
|
1030
1564
|
Some(json!({"slug": "yoga", "capacity": null, "seats_taken": 0, "seats_left": null}))
|
|
@@ -1034,19 +1568,42 @@ mod tests {
|
|
|
1034
1568
|
#[test]
|
|
1035
1569
|
fn the_seat_figures_agree_with_seats_left_for_every_event() {
|
|
1036
1570
|
let payments = crate::commerce_ir::fixture_payments();
|
|
1037
|
-
let mut read = read_with(
|
|
1571
|
+
let mut read = read_with(
|
|
1572
|
+
6,
|
|
1573
|
+
&[
|
|
1574
|
+
("a", "yoga", "succeeded"),
|
|
1575
|
+
("b", "yoga", "refunding"),
|
|
1576
|
+
("c", "other", "pending"),
|
|
1577
|
+
("d", "yoga", "failed"),
|
|
1578
|
+
],
|
|
1579
|
+
);
|
|
1038
1580
|
set_registration_status(&mut read, "b", "archived");
|
|
1039
1581
|
let all = all_seat_figures(&read, "CheckoutFixture", &payments);
|
|
1040
1582
|
for slug in ["yoga", "other"] {
|
|
1041
|
-
assert_eq!(
|
|
1042
|
-
|
|
1583
|
+
assert_eq!(
|
|
1584
|
+
all["events"][slug]["seats_left"].as_i64(),
|
|
1585
|
+
seats_left(&read, "CheckoutFixture", &payments, slug),
|
|
1586
|
+
"{slug}"
|
|
1587
|
+
);
|
|
1588
|
+
assert_eq!(
|
|
1589
|
+
all["events"][slug]["seats_taken"].as_u64(),
|
|
1590
|
+
Some(seats_taken(&read, "CheckoutFixture", &payments, slug) as u64),
|
|
1591
|
+
"{slug}"
|
|
1592
|
+
);
|
|
1043
1593
|
}
|
|
1044
1594
|
}
|
|
1045
1595
|
|
|
1046
1596
|
#[test]
|
|
1047
1597
|
fn only_a_single_segment_slug_between_events_and_seats_names_an_event() {
|
|
1048
1598
|
assert_eq!(seats_path_slug("/events/yoga/seats"), Some("yoga"));
|
|
1049
|
-
for path in [
|
|
1599
|
+
for path in [
|
|
1600
|
+
"/events/seats",
|
|
1601
|
+
"/events//seats",
|
|
1602
|
+
"/events/a/b/seats",
|
|
1603
|
+
"/events/yoga",
|
|
1604
|
+
"/events/yoga/seats/x",
|
|
1605
|
+
"/registrations/x/seats",
|
|
1606
|
+
] {
|
|
1050
1607
|
assert_eq!(seats_path_slug(path), None, "{path}");
|
|
1051
1608
|
}
|
|
1052
1609
|
}
|
|
@@ -1054,7 +1611,13 @@ mod tests {
|
|
|
1054
1611
|
#[tokio::test]
|
|
1055
1612
|
async fn the_seat_routes_answer_from_a_real_read_and_404_an_unknown_event() {
|
|
1056
1613
|
let client = scratch_db("hecks_host_web_test_seat_routes").await;
|
|
1057
|
-
provision_lineage(
|
|
1614
|
+
provision_lineage(
|
|
1615
|
+
&*client.lock().await,
|
|
1616
|
+
"CheckoutFixture",
|
|
1617
|
+
1,
|
|
1618
|
+
&["Event", "Registration", "Payment"],
|
|
1619
|
+
)
|
|
1620
|
+
.await;
|
|
1058
1621
|
let config = checkout_config(1);
|
|
1059
1622
|
let wasm_path = checkout_wasm_path();
|
|
1060
1623
|
schedule_event(&client, &wasm_path, &config, "yoga", 4200).await;
|
|
@@ -1062,13 +1625,40 @@ mod tests {
|
|
|
1062
1625
|
let get = |path: &'static str| {
|
|
1063
1626
|
let (client, wasm_path, config, ir) = (&client, &wasm_path, &config, &ir);
|
|
1064
1627
|
async move {
|
|
1065
|
-
let response = payments_routes(
|
|
1066
|
-
|
|
1628
|
+
let response = payments_routes(
|
|
1629
|
+
Some(ir),
|
|
1630
|
+
"GET",
|
|
1631
|
+
path,
|
|
1632
|
+
"",
|
|
1633
|
+
"",
|
|
1634
|
+
Host::new(client, wasm_path, config, &lambda_client::NeverInvoker),
|
|
1635
|
+
)
|
|
1636
|
+
.await
|
|
1637
|
+
.expect("a seats path");
|
|
1638
|
+
(
|
|
1639
|
+
response["statusCode"].as_u64().unwrap(),
|
|
1640
|
+
serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap(),
|
|
1641
|
+
)
|
|
1067
1642
|
}
|
|
1068
1643
|
};
|
|
1069
|
-
assert_eq!(
|
|
1070
|
-
|
|
1071
|
-
|
|
1644
|
+
assert_eq!(
|
|
1645
|
+
get("/events/yoga/seats").await,
|
|
1646
|
+
(
|
|
1647
|
+
200,
|
|
1648
|
+
json!({"slug": "yoga", "capacity": 20, "seats_taken": 0, "seats_left": 20})
|
|
1649
|
+
)
|
|
1650
|
+
);
|
|
1651
|
+
assert_eq!(
|
|
1652
|
+
get("/events/seats").await,
|
|
1653
|
+
(
|
|
1654
|
+
200,
|
|
1655
|
+
json!({"events": {"yoga": {"capacity": 20, "seats_taken": 0, "seats_left": 20}}})
|
|
1656
|
+
)
|
|
1657
|
+
);
|
|
1658
|
+
assert_eq!(
|
|
1659
|
+
get("/events/nowhere/seats").await,
|
|
1660
|
+
(404, json!({"error": "no such event"}))
|
|
1661
|
+
);
|
|
1072
1662
|
}
|
|
1073
1663
|
|
|
1074
1664
|
#[test]
|
|
@@ -1096,8 +1686,16 @@ mod tests {
|
|
|
1096
1686
|
|
|
1097
1687
|
let attendee = attendee_from(&body);
|
|
1098
1688
|
|
|
1099
|
-
assert_eq!(
|
|
1100
|
-
|
|
1689
|
+
assert_eq!(
|
|
1690
|
+
attendee.get("event_slug"),
|
|
1691
|
+
None,
|
|
1692
|
+
"routing metadata must not leak into Attendee"
|
|
1693
|
+
);
|
|
1694
|
+
assert_eq!(
|
|
1695
|
+
attendee.get("return_to"),
|
|
1696
|
+
None,
|
|
1697
|
+
"routing metadata must not leak into Attendee"
|
|
1698
|
+
);
|
|
1101
1699
|
assert_eq!(attendee["first_name"], "Ada");
|
|
1102
1700
|
assert_eq!(attendee["last_name"], "Lovelace");
|
|
1103
1701
|
assert_eq!(attendee["email"], "ada@example.com");
|
|
@@ -1113,7 +1711,10 @@ mod tests {
|
|
|
1113
1711
|
// Must round-trip byte-for-byte — this exact shape is what
|
|
1114
1712
|
// CheckoutFixture's tests, and any simple-Attendee domain, send.
|
|
1115
1713
|
let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"});
|
|
1116
|
-
assert_eq!(
|
|
1714
|
+
assert_eq!(
|
|
1715
|
+
attendee_from(&body),
|
|
1716
|
+
json!({"name": "Ada Lovelace", "email": "ada@example.com"})
|
|
1717
|
+
);
|
|
1117
1718
|
}
|
|
1118
1719
|
|
|
1119
1720
|
#[test]
|
|
@@ -1130,17 +1731,45 @@ mod tests {
|
|
|
1130
1731
|
|
|
1131
1732
|
#[test]
|
|
1132
1733
|
fn display_name_from_is_none_when_neither_shape_is_present() {
|
|
1133
|
-
assert_eq!(
|
|
1134
|
-
|
|
1135
|
-
|
|
1734
|
+
assert_eq!(
|
|
1735
|
+
display_name_from(&json!({"email": "ada@example.com"})),
|
|
1736
|
+
None
|
|
1737
|
+
);
|
|
1738
|
+
assert_eq!(
|
|
1739
|
+
display_name_from(&json!({"first_name": "Ada"})),
|
|
1740
|
+
None,
|
|
1741
|
+
"last_name alone is missing"
|
|
1742
|
+
);
|
|
1743
|
+
assert_eq!(
|
|
1744
|
+
display_name_from(&json!({"last_name": "Lovelace"})),
|
|
1745
|
+
None,
|
|
1746
|
+
"first_name alone is missing"
|
|
1747
|
+
);
|
|
1136
1748
|
}
|
|
1137
1749
|
|
|
1138
1750
|
#[tokio::test]
|
|
1139
1751
|
async fn registrations_route_refuses_a_body_missing_any_required_field() {
|
|
1140
1752
|
let client = scratch_db("hecks_host_web_test_registrations_missing_fields").await;
|
|
1141
|
-
provision_lineage(
|
|
1142
|
-
|
|
1143
|
-
|
|
1753
|
+
provision_lineage(
|
|
1754
|
+
&*client.lock().await,
|
|
1755
|
+
"CheckoutFixture",
|
|
1756
|
+
1,
|
|
1757
|
+
&["Event", "Registration", "Payment"],
|
|
1758
|
+
)
|
|
1759
|
+
.await;
|
|
1760
|
+
|
|
1761
|
+
let response = registrations_route(
|
|
1762
|
+
r#"{"event_slug":"yoga-aug"}"#,
|
|
1763
|
+
&payments::test_platform(),
|
|
1764
|
+
Host::new(
|
|
1765
|
+
&client,
|
|
1766
|
+
&checkout_wasm_path(),
|
|
1767
|
+
&checkout_config(1),
|
|
1768
|
+
&lambda_client::NeverInvoker,
|
|
1769
|
+
),
|
|
1770
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1771
|
+
)
|
|
1772
|
+
.await;
|
|
1144
1773
|
assert_eq!(response["statusCode"], 400);
|
|
1145
1774
|
assert!(response["body"].as_str().unwrap().contains("missing name"));
|
|
1146
1775
|
}
|
|
@@ -1149,9 +1778,16 @@ mod tests {
|
|
|
1149
1778
|
// `name`-only Attendee — proves display_name_from composes a real Client
|
|
1150
1779
|
// name and attendee_from forwards fields verbatim, without a second fixture.
|
|
1151
1780
|
#[tokio::test]
|
|
1152
|
-
async fn registrations_route_forwards_a_new_shaped_attendee_verbatim_even_against_an_old_shaped_fixture(
|
|
1781
|
+
async fn registrations_route_forwards_a_new_shaped_attendee_verbatim_even_against_an_old_shaped_fixture(
|
|
1782
|
+
) {
|
|
1153
1783
|
let client = scratch_db("hecks_host_web_test_registrations_new_shape").await;
|
|
1154
|
-
provision_lineage(
|
|
1784
|
+
provision_lineage(
|
|
1785
|
+
&*client.lock().await,
|
|
1786
|
+
"CheckoutFixture",
|
|
1787
|
+
1,
|
|
1788
|
+
&["Event", "Registration", "Payment"],
|
|
1789
|
+
)
|
|
1790
|
+
.await;
|
|
1155
1791
|
let config = checkout_config(1);
|
|
1156
1792
|
let wasm_path = checkout_wasm_path();
|
|
1157
1793
|
|
|
@@ -1164,7 +1800,13 @@ mod tests {
|
|
|
1164
1800
|
"email": "ada@example.com",
|
|
1165
1801
|
})
|
|
1166
1802
|
.to_string();
|
|
1167
|
-
let response = registrations_route(
|
|
1803
|
+
let response = registrations_route(
|
|
1804
|
+
&body,
|
|
1805
|
+
&payments::test_platform(),
|
|
1806
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
1807
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1808
|
+
)
|
|
1809
|
+
.await;
|
|
1168
1810
|
|
|
1169
1811
|
// Registration.Request refuses (no first_name/last_name in this
|
|
1170
1812
|
// Attendee) but only after Payment.Initiate already ran, proving
|
|
@@ -1182,7 +1824,9 @@ mod tests {
|
|
|
1182
1824
|
"Payment.Initiate should have committed for real using the composed display name: {instances:?}"
|
|
1183
1825
|
);
|
|
1184
1826
|
assert!(
|
|
1185
|
-
instances
|
|
1827
|
+
instances
|
|
1828
|
+
.keys()
|
|
1829
|
+
.all(|k| !k.starts_with("CheckoutFixture::Registration#")),
|
|
1186
1830
|
"Registration.Request must never have committed: {instances:?}"
|
|
1187
1831
|
);
|
|
1188
1832
|
}
|
|
@@ -1207,40 +1851,109 @@ mod tests {
|
|
|
1207
1851
|
("GET", "/events/yoga/seats"),
|
|
1208
1852
|
] {
|
|
1209
1853
|
for ir in [Some(&no_payments), None] {
|
|
1210
|
-
let response = payments_routes(
|
|
1211
|
-
|
|
1854
|
+
let response = payments_routes(
|
|
1855
|
+
ir,
|
|
1856
|
+
method,
|
|
1857
|
+
path,
|
|
1858
|
+
"not json",
|
|
1859
|
+
"",
|
|
1860
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
1861
|
+
)
|
|
1862
|
+
.await;
|
|
1863
|
+
assert!(
|
|
1864
|
+
response.is_none(),
|
|
1865
|
+
"{method} {path} answered although the IR declares no payments: {response:?}"
|
|
1866
|
+
);
|
|
1212
1867
|
}
|
|
1213
1868
|
}
|
|
1214
1869
|
|
|
1215
1870
|
let fixture_ir = crate::commerce_ir::fixture_ir();
|
|
1216
|
-
let response = payments_routes(
|
|
1217
|
-
|
|
1871
|
+
let response = payments_routes(
|
|
1872
|
+
Some(&fixture_ir),
|
|
1873
|
+
"POST",
|
|
1874
|
+
"/registrations",
|
|
1875
|
+
"not json",
|
|
1876
|
+
"",
|
|
1877
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
1878
|
+
)
|
|
1879
|
+
.await;
|
|
1880
|
+
assert_eq!(
|
|
1881
|
+
response.expect("the fixture IR declares payments, so the route answers")["statusCode"],
|
|
1882
|
+
400
|
|
1883
|
+
);
|
|
1218
1884
|
}
|
|
1219
1885
|
|
|
1220
1886
|
#[tokio::test]
|
|
1221
1887
|
async fn registrations_route_refuses_invalid_json_outright() {
|
|
1222
1888
|
let client = scratch_db("hecks_host_web_test_registrations_bad_json").await;
|
|
1223
|
-
provision_lineage(
|
|
1224
|
-
|
|
1225
|
-
|
|
1889
|
+
provision_lineage(
|
|
1890
|
+
&*client.lock().await,
|
|
1891
|
+
"CheckoutFixture",
|
|
1892
|
+
1,
|
|
1893
|
+
&["Event", "Registration", "Payment"],
|
|
1894
|
+
)
|
|
1895
|
+
.await;
|
|
1896
|
+
|
|
1897
|
+
let response = registrations_route(
|
|
1898
|
+
"not json",
|
|
1899
|
+
&payments::test_platform(),
|
|
1900
|
+
Host::new(
|
|
1901
|
+
&client,
|
|
1902
|
+
&checkout_wasm_path(),
|
|
1903
|
+
&checkout_config(1),
|
|
1904
|
+
&lambda_client::NeverInvoker,
|
|
1905
|
+
),
|
|
1906
|
+
&crate::commerce_ir::fixture_payments(),
|
|
1907
|
+
)
|
|
1908
|
+
.await;
|
|
1226
1909
|
assert_eq!(response["statusCode"], 400);
|
|
1227
1910
|
}
|
|
1228
1911
|
|
|
1229
1912
|
#[tokio::test]
|
|
1230
1913
|
async fn events_route_refuses_invalid_json_outright() {
|
|
1231
1914
|
let client = scratch_db("hecks_host_web_test_events_bad_json").await;
|
|
1232
|
-
provision_lineage(
|
|
1233
|
-
|
|
1234
|
-
|
|
1915
|
+
provision_lineage(
|
|
1916
|
+
&*client.lock().await,
|
|
1917
|
+
"CheckoutFixture",
|
|
1918
|
+
1,
|
|
1919
|
+
&["Event", "Registration", "Payment"],
|
|
1920
|
+
)
|
|
1921
|
+
.await;
|
|
1922
|
+
|
|
1923
|
+
let response = events_route(
|
|
1924
|
+
"not json",
|
|
1925
|
+
Host::new(
|
|
1926
|
+
&client,
|
|
1927
|
+
&checkout_wasm_path(),
|
|
1928
|
+
&checkout_config(1),
|
|
1929
|
+
&lambda_client::NeverInvoker,
|
|
1930
|
+
),
|
|
1931
|
+
)
|
|
1932
|
+
.await;
|
|
1235
1933
|
assert_eq!(response["statusCode"], 400);
|
|
1236
1934
|
}
|
|
1237
1935
|
|
|
1238
1936
|
#[tokio::test]
|
|
1239
1937
|
async fn events_route_refuses_a_body_missing_any_required_field() {
|
|
1240
1938
|
let client = scratch_db("hecks_host_web_test_events_missing_fields").await;
|
|
1241
|
-
provision_lineage(
|
|
1242
|
-
|
|
1243
|
-
|
|
1939
|
+
provision_lineage(
|
|
1940
|
+
&*client.lock().await,
|
|
1941
|
+
"CheckoutFixture",
|
|
1942
|
+
1,
|
|
1943
|
+
&["Event", "Registration", "Payment"],
|
|
1944
|
+
)
|
|
1945
|
+
.await;
|
|
1946
|
+
|
|
1947
|
+
let response = events_route(
|
|
1948
|
+
r#"{"slug":"new-event"}"#,
|
|
1949
|
+
Host::new(
|
|
1950
|
+
&client,
|
|
1951
|
+
&checkout_wasm_path(),
|
|
1952
|
+
&checkout_config(1),
|
|
1953
|
+
&lambda_client::NeverInvoker,
|
|
1954
|
+
),
|
|
1955
|
+
)
|
|
1956
|
+
.await;
|
|
1244
1957
|
assert_eq!(response["statusCode"], 400);
|
|
1245
1958
|
assert!(response["body"].as_str().unwrap().contains("missing name"));
|
|
1246
1959
|
}
|
|
@@ -1248,14 +1961,25 @@ mod tests {
|
|
|
1248
1961
|
#[tokio::test]
|
|
1249
1962
|
async fn events_route_schedules_a_new_event_and_returns_201() {
|
|
1250
1963
|
let client = scratch_db("hecks_host_web_test_events_new").await;
|
|
1251
|
-
provision_lineage(
|
|
1964
|
+
provision_lineage(
|
|
1965
|
+
&*client.lock().await,
|
|
1966
|
+
"CheckoutFixture",
|
|
1967
|
+
1,
|
|
1968
|
+
&["Event", "Registration", "Payment"],
|
|
1969
|
+
)
|
|
1970
|
+
.await;
|
|
1252
1971
|
let config = checkout_config(1);
|
|
1253
1972
|
let wasm_path = checkout_wasm_path();
|
|
1254
1973
|
|
|
1255
1974
|
let body = json!({"slug": "mock-payments-event", "name": "Mock Payments Event", "price_cents": 4200, "capacity": 20}).to_string();
|
|
1256
|
-
let response = events_route(
|
|
1975
|
+
let response = events_route(
|
|
1976
|
+
&body,
|
|
1977
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
1978
|
+
)
|
|
1979
|
+
.await;
|
|
1257
1980
|
assert_eq!(response["statusCode"], 201, "{response:?}");
|
|
1258
|
-
let response_body: Value =
|
|
1981
|
+
let response_body: Value =
|
|
1982
|
+
serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1259
1983
|
assert_eq!(response_body["slug"]["value"], "mock-payments-event");
|
|
1260
1984
|
assert_eq!(response_body["name"]["value"], "Mock Payments Event");
|
|
1261
1985
|
assert_eq!(response_body["price"]["cents"], 4200);
|
|
@@ -1265,7 +1989,9 @@ mod tests {
|
|
|
1265
1989
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1266
1990
|
let instances = read["instances"].as_object().unwrap();
|
|
1267
1991
|
assert!(
|
|
1268
|
-
instances
|
|
1992
|
+
instances
|
|
1993
|
+
.keys()
|
|
1994
|
+
.any(|k| k == "CheckoutFixture::Event#mock-payments-event"),
|
|
1269
1995
|
"Event.Schedule should have committed for real: {instances:?}"
|
|
1270
1996
|
);
|
|
1271
1997
|
}
|
|
@@ -1275,27 +2001,47 @@ mod tests {
|
|
|
1275
2001
|
#[tokio::test]
|
|
1276
2002
|
async fn events_route_is_idempotent_on_a_repeat_slug() {
|
|
1277
2003
|
let client = scratch_db("hecks_host_web_test_events_idempotent").await;
|
|
1278
|
-
provision_lineage(
|
|
2004
|
+
provision_lineage(
|
|
2005
|
+
&*client.lock().await,
|
|
2006
|
+
"CheckoutFixture",
|
|
2007
|
+
1,
|
|
2008
|
+
&["Event", "Registration", "Payment"],
|
|
2009
|
+
)
|
|
2010
|
+
.await;
|
|
1279
2011
|
let config = checkout_config(1);
|
|
1280
2012
|
let wasm_path = checkout_wasm_path();
|
|
1281
2013
|
|
|
1282
2014
|
let body = json!({"slug": "repeat-event", "name": "Repeat Event", "price_cents": 1000, "capacity": 5}).to_string();
|
|
1283
|
-
let first = events_route(
|
|
2015
|
+
let first = events_route(
|
|
2016
|
+
&body,
|
|
2017
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2018
|
+
)
|
|
2019
|
+
.await;
|
|
1284
2020
|
assert_eq!(first["statusCode"], 201, "{first:?}");
|
|
1285
2021
|
|
|
1286
2022
|
// A second call with different name/price/capacity still returns the
|
|
1287
2023
|
// original event's state, 200 not 201 — idempotency keys only on slug.
|
|
1288
2024
|
let second_body = json!({"slug": "repeat-event", "name": "Different Name", "price_cents": 9999, "capacity": 1}).to_string();
|
|
1289
|
-
let second = events_route(
|
|
2025
|
+
let second = events_route(
|
|
2026
|
+
&second_body,
|
|
2027
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2028
|
+
)
|
|
2029
|
+
.await;
|
|
1290
2030
|
assert_eq!(second["statusCode"], 200, "{second:?}");
|
|
1291
2031
|
let second_body: Value = serde_json::from_str(second["body"].as_str().unwrap()).unwrap();
|
|
1292
|
-
assert_eq!(
|
|
2032
|
+
assert_eq!(
|
|
2033
|
+
second_body["name"]["value"], "Repeat Event",
|
|
2034
|
+
"must return the ORIGINAL event, not re-schedule with the new fields"
|
|
2035
|
+
);
|
|
1293
2036
|
assert_eq!(second_body["price"]["cents"], 1000);
|
|
1294
2037
|
|
|
1295
2038
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1296
2039
|
let instances = read["instances"].as_object().unwrap();
|
|
1297
2040
|
assert_eq!(
|
|
1298
|
-
instances
|
|
2041
|
+
instances
|
|
2042
|
+
.keys()
|
|
2043
|
+
.filter(|k| k.starts_with("CheckoutFixture::Event#repeat-event"))
|
|
2044
|
+
.count(),
|
|
1299
2045
|
1,
|
|
1300
2046
|
"exactly one Event, never a second dispatch: {instances:?}"
|
|
1301
2047
|
);
|
|
@@ -1304,35 +2050,82 @@ mod tests {
|
|
|
1304
2050
|
#[tokio::test]
|
|
1305
2051
|
async fn registrations_route_404s_an_unknown_event_slug() {
|
|
1306
2052
|
let client = scratch_db("hecks_host_web_test_registrations_no_event").await;
|
|
1307
|
-
provision_lineage(
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
2053
|
+
provision_lineage(
|
|
2054
|
+
&*client.lock().await,
|
|
2055
|
+
"CheckoutFixture",
|
|
2056
|
+
1,
|
|
2057
|
+
&["Event", "Registration", "Payment"],
|
|
2058
|
+
)
|
|
2059
|
+
.await;
|
|
2060
|
+
|
|
2061
|
+
let body =
|
|
2062
|
+
json!({"event_slug": "nope", "name": "Ada", "email": "ada@example.com"}).to_string();
|
|
2063
|
+
let response = registrations_route(
|
|
2064
|
+
&body,
|
|
2065
|
+
&payments::test_platform(),
|
|
2066
|
+
Host::new(
|
|
2067
|
+
&client,
|
|
2068
|
+
&checkout_wasm_path(),
|
|
2069
|
+
&checkout_config(1),
|
|
2070
|
+
&lambda_client::NeverInvoker,
|
|
2071
|
+
),
|
|
2072
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2073
|
+
)
|
|
2074
|
+
.await;
|
|
1311
2075
|
assert_eq!(response["statusCode"], 404);
|
|
1312
2076
|
}
|
|
1313
2077
|
|
|
1314
2078
|
#[tokio::test]
|
|
1315
2079
|
async fn registrations_route_refuses_a_closed_event() {
|
|
1316
2080
|
let client = scratch_db("hecks_host_web_test_registrations_closed_event").await;
|
|
1317
|
-
provision_lineage(
|
|
2081
|
+
provision_lineage(
|
|
2082
|
+
&*client.lock().await,
|
|
2083
|
+
"CheckoutFixture",
|
|
2084
|
+
1,
|
|
2085
|
+
&["Event", "Registration", "Payment"],
|
|
2086
|
+
)
|
|
2087
|
+
.await;
|
|
1318
2088
|
let config = checkout_config(1);
|
|
1319
2089
|
let wasm_path = checkout_wasm_path();
|
|
1320
2090
|
|
|
1321
2091
|
schedule_event(&client, &wasm_path, &config, "closed-event", 4200).await;
|
|
1322
|
-
let close = dispatch::handle(
|
|
1323
|
-
|
|
1324
|
-
.
|
|
1325
|
-
|
|
2092
|
+
let close = dispatch::handle(
|
|
2093
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2094
|
+
"CheckoutFixture::Event.Close",
|
|
2095
|
+
json!({"id": "closed-event"}),
|
|
2096
|
+
None,
|
|
2097
|
+
)
|
|
2098
|
+
.await
|
|
2099
|
+
.unwrap();
|
|
2100
|
+
assert!(
|
|
2101
|
+
close.accepted,
|
|
2102
|
+
"closing the fixture event should succeed: {:?}",
|
|
2103
|
+
close.result
|
|
2104
|
+
);
|
|
1326
2105
|
|
|
1327
|
-
let body = json!({"event_slug": "closed-event", "name": "Ada", "email": "ada@example.com"})
|
|
1328
|
-
|
|
2106
|
+
let body = json!({"event_slug": "closed-event", "name": "Ada", "email": "ada@example.com"})
|
|
2107
|
+
.to_string();
|
|
2108
|
+
let response = registrations_route(
|
|
2109
|
+
&body,
|
|
2110
|
+
&payments::test_platform(),
|
|
2111
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2112
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2113
|
+
)
|
|
2114
|
+
.await;
|
|
1329
2115
|
assert_eq!(response["statusCode"], 422);
|
|
1330
2116
|
assert!(response["body"].as_str().unwrap().contains("closed"));
|
|
1331
2117
|
}
|
|
1332
2118
|
|
|
1333
2119
|
// Schedules `slug` starting at `starts_at` with the optional cutoff set
|
|
1334
2120
|
// (or absent), through the real Event.Schedule command.
|
|
1335
|
-
async fn schedule_timed_event(
|
|
2121
|
+
async fn schedule_timed_event(
|
|
2122
|
+
client: &Mutex<Client>,
|
|
2123
|
+
wasm_path: &Path,
|
|
2124
|
+
config: &LineageConfig,
|
|
2125
|
+
slug: &str,
|
|
2126
|
+
starts_at: i64,
|
|
2127
|
+
cutoff_hours: Option<i64>,
|
|
2128
|
+
) {
|
|
1336
2129
|
let mut args = json!({
|
|
1337
2130
|
"slug": {"value": slug}, "name": {"value": "Sample Studio"},
|
|
1338
2131
|
"price": {"cents": 4200}, "capacity": {"value": 20},
|
|
@@ -1341,25 +2134,57 @@ mod tests {
|
|
|
1341
2134
|
if let Some(hours) = cutoff_hours {
|
|
1342
2135
|
args["registration_cutoff_hours"] = json!({"value": hours});
|
|
1343
2136
|
}
|
|
1344
|
-
let outcome = dispatch::handle(
|
|
1345
|
-
|
|
1346
|
-
.
|
|
1347
|
-
|
|
2137
|
+
let outcome = dispatch::handle(
|
|
2138
|
+
Host::new(client, wasm_path, config, &lambda_client::NeverInvoker),
|
|
2139
|
+
"CheckoutFixture::Event.Schedule",
|
|
2140
|
+
args,
|
|
2141
|
+
None,
|
|
2142
|
+
)
|
|
2143
|
+
.await
|
|
2144
|
+
.unwrap();
|
|
2145
|
+
assert!(
|
|
2146
|
+
outcome.accepted,
|
|
2147
|
+
"scheduling the timed fixture event should succeed: {:?}",
|
|
2148
|
+
outcome.result
|
|
2149
|
+
);
|
|
1348
2150
|
}
|
|
1349
2151
|
|
|
1350
2152
|
#[tokio::test]
|
|
1351
|
-
async fn registrations_route_refuses_a_session_inside_its_registration_cutoff_and_writes_nothing(
|
|
2153
|
+
async fn registrations_route_refuses_a_session_inside_its_registration_cutoff_and_writes_nothing(
|
|
2154
|
+
) {
|
|
1352
2155
|
let client = scratch_db("hecks_host_web_test_registrations_cutoff_passed").await;
|
|
1353
|
-
provision_lineage(
|
|
2156
|
+
provision_lineage(
|
|
2157
|
+
&*client.lock().await,
|
|
2158
|
+
"CheckoutFixture",
|
|
2159
|
+
1,
|
|
2160
|
+
&["Event", "Registration", "Payment"],
|
|
2161
|
+
)
|
|
2162
|
+
.await;
|
|
1354
2163
|
let config = checkout_config(1);
|
|
1355
2164
|
let wasm_path = checkout_wasm_path();
|
|
1356
2165
|
|
|
1357
2166
|
// Starts in 5 hours with a 6 hour cutoff: registration closed an hour ago.
|
|
1358
2167
|
let now = 1_800_000_000;
|
|
1359
|
-
schedule_timed_event(
|
|
2168
|
+
schedule_timed_event(
|
|
2169
|
+
&client,
|
|
2170
|
+
&wasm_path,
|
|
2171
|
+
&config,
|
|
2172
|
+
"too-late",
|
|
2173
|
+
now + 5 * 3600,
|
|
2174
|
+
Some(6),
|
|
2175
|
+
)
|
|
2176
|
+
.await;
|
|
1360
2177
|
|
|
1361
|
-
let body = json!({"event_slug": "too-late", "name": "Ada", "email": "ada@example.com"})
|
|
1362
|
-
|
|
2178
|
+
let body = json!({"event_slug": "too-late", "name": "Ada", "email": "ada@example.com"})
|
|
2179
|
+
.to_string();
|
|
2180
|
+
let response = registrations_route_at(
|
|
2181
|
+
&body,
|
|
2182
|
+
&payments::test_platform(),
|
|
2183
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2184
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2185
|
+
now,
|
|
2186
|
+
)
|
|
2187
|
+
.await;
|
|
1363
2188
|
assert_eq!(response["statusCode"], 422, "{response:?}");
|
|
1364
2189
|
assert_eq!(
|
|
1365
2190
|
serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap(),
|
|
@@ -1369,7 +2194,10 @@ mod tests {
|
|
|
1369
2194
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1370
2195
|
let instances = read["instances"].as_object().unwrap();
|
|
1371
2196
|
assert!(
|
|
1372
|
-
instances
|
|
2197
|
+
instances
|
|
2198
|
+
.keys()
|
|
2199
|
+
.all(|k| !k.starts_with("Payments::Payment#")
|
|
2200
|
+
&& !k.starts_with("CheckoutFixture::Registration#")),
|
|
1373
2201
|
"a refused registration must write no Payment and no Registration: {instances:?}"
|
|
1374
2202
|
);
|
|
1375
2203
|
}
|
|
@@ -1377,7 +2205,13 @@ mod tests {
|
|
|
1377
2205
|
#[tokio::test]
|
|
1378
2206
|
async fn registrations_route_accepts_a_session_with_no_cutoff_even_when_it_has_a_start() {
|
|
1379
2207
|
let client = scratch_db("hecks_host_web_test_registrations_cutoff_absent").await;
|
|
1380
|
-
provision_lineage(
|
|
2208
|
+
provision_lineage(
|
|
2209
|
+
&*client.lock().await,
|
|
2210
|
+
"CheckoutFixture",
|
|
2211
|
+
1,
|
|
2212
|
+
&["Event", "Registration", "Payment"],
|
|
2213
|
+
)
|
|
2214
|
+
.await;
|
|
1381
2215
|
let config = checkout_config(1);
|
|
1382
2216
|
let wasm_path = checkout_wasm_path();
|
|
1383
2217
|
|
|
@@ -1385,14 +2219,32 @@ mod tests {
|
|
|
1385
2219
|
let now = 1_800_000_000;
|
|
1386
2220
|
schedule_timed_event(&client, &wasm_path, &config, "no-cutoff", now - 3600, None).await;
|
|
1387
2221
|
|
|
1388
|
-
let body = json!({"event_slug": "no-cutoff", "name": "Ada", "email": "ada@example.com"})
|
|
1389
|
-
|
|
2222
|
+
let body = json!({"event_slug": "no-cutoff", "name": "Ada", "email": "ada@example.com"})
|
|
2223
|
+
.to_string();
|
|
2224
|
+
let response = registrations_route_at(
|
|
2225
|
+
&body,
|
|
2226
|
+
&payments::test_platform(),
|
|
2227
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2228
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2229
|
+
now,
|
|
2230
|
+
)
|
|
2231
|
+
.await;
|
|
1390
2232
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1391
2233
|
|
|
1392
2234
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1393
2235
|
let instances = read["instances"].as_object().unwrap();
|
|
1394
|
-
assert!(
|
|
1395
|
-
|
|
2236
|
+
assert!(
|
|
2237
|
+
instances
|
|
2238
|
+
.keys()
|
|
2239
|
+
.any(|k| k.starts_with("CheckoutFixture::Registration#")),
|
|
2240
|
+
"{instances:?}"
|
|
2241
|
+
);
|
|
2242
|
+
assert!(
|
|
2243
|
+
instances
|
|
2244
|
+
.keys()
|
|
2245
|
+
.any(|k| k.starts_with("Payments::Payment#")),
|
|
2246
|
+
"{instances:?}"
|
|
2247
|
+
);
|
|
1396
2248
|
}
|
|
1397
2249
|
|
|
1398
2250
|
#[test]
|
|
@@ -1400,13 +2252,34 @@ mod tests {
|
|
|
1400
2252
|
let now = 1_000_000;
|
|
1401
2253
|
let past_start = json!({"starts_at": 0});
|
|
1402
2254
|
assert!(!registration_cutoff_passed(&json!({}), now));
|
|
1403
|
-
assert!(
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
assert!(
|
|
1408
|
-
|
|
1409
|
-
|
|
2255
|
+
assert!(
|
|
2256
|
+
!registration_cutoff_passed(&past_start, now),
|
|
2257
|
+
"a start with no cutoff hours is no cutoff"
|
|
2258
|
+
);
|
|
2259
|
+
assert!(
|
|
2260
|
+
!registration_cutoff_passed(&json!({"registration_cutoff_hours": 48}), now),
|
|
2261
|
+
"hours with no start is no cutoff"
|
|
2262
|
+
);
|
|
2263
|
+
assert!(!registration_cutoff_passed(
|
|
2264
|
+
&json!({"starts_at": null, "registration_cutoff_hours": 48}),
|
|
2265
|
+
now
|
|
2266
|
+
));
|
|
2267
|
+
assert!(!registration_cutoff_passed(
|
|
2268
|
+
&json!({"starts_at": 0, "registration_cutoff_hours": null}),
|
|
2269
|
+
now
|
|
2270
|
+
));
|
|
2271
|
+
assert!(!registration_cutoff_passed(
|
|
2272
|
+
&json!({"starts_at": "soon", "registration_cutoff_hours": 48}),
|
|
2273
|
+
now
|
|
2274
|
+
));
|
|
2275
|
+
assert!(!registration_cutoff_passed(
|
|
2276
|
+
&json!({"starts_at": 0, "registration_cutoff_hours": "two"}),
|
|
2277
|
+
now
|
|
2278
|
+
));
|
|
2279
|
+
assert!(!registration_cutoff_passed(
|
|
2280
|
+
&json!({"starts_at": {"value": "soon"}, "registration_cutoff_hours": {"value": 1.5}}),
|
|
2281
|
+
now
|
|
2282
|
+
));
|
|
1410
2283
|
}
|
|
1411
2284
|
|
|
1412
2285
|
#[test]
|
|
@@ -1414,13 +2287,17 @@ mod tests {
|
|
|
1414
2287
|
// Cutoff 2 hours before a start of 100_000: the window shuts after 92_800.
|
|
1415
2288
|
let event = json!({"starts_at": 100_000, "registration_cutoff_hours": 2});
|
|
1416
2289
|
assert!(!registration_cutoff_passed(&event, 92_799));
|
|
1417
|
-
assert!(
|
|
2290
|
+
assert!(
|
|
2291
|
+
!registration_cutoff_passed(&event, 92_800),
|
|
2292
|
+
"exactly at the cutoff is still open"
|
|
2293
|
+
);
|
|
1418
2294
|
assert!(registration_cutoff_passed(&event, 92_801));
|
|
1419
2295
|
}
|
|
1420
2296
|
|
|
1421
2297
|
#[test]
|
|
1422
2298
|
fn a_registration_cutoff_reads_a_value_object_or_a_bare_number() {
|
|
1423
|
-
let wrapped =
|
|
2299
|
+
let wrapped =
|
|
2300
|
+
json!({"starts_at": {"value": 100_000}, "registration_cutoff_hours": {"value": 2}});
|
|
1424
2301
|
let bare = json!({"starts_at": 100_000, "registration_cutoff_hours": 2});
|
|
1425
2302
|
let mixed = json!({"starts_at": {"value": 100_000}, "registration_cutoff_hours": 2});
|
|
1426
2303
|
for event in [wrapped, bare, mixed] {
|
|
@@ -1444,14 +2321,27 @@ mod tests {
|
|
|
1444
2321
|
// Payment.Initiate before Registration.Request is ever reached,
|
|
1445
2322
|
// proving the refusal is the real domain rule.
|
|
1446
2323
|
let client = scratch_db("hecks_host_web_test_registrations_zero_price").await;
|
|
1447
|
-
provision_lineage(
|
|
2324
|
+
provision_lineage(
|
|
2325
|
+
&*client.lock().await,
|
|
2326
|
+
"CheckoutFixture",
|
|
2327
|
+
1,
|
|
2328
|
+
&["Event", "Registration", "Payment"],
|
|
2329
|
+
)
|
|
2330
|
+
.await;
|
|
1448
2331
|
let config = checkout_config(1);
|
|
1449
2332
|
let wasm_path = checkout_wasm_path();
|
|
1450
2333
|
|
|
1451
2334
|
schedule_event(&client, &wasm_path, &config, "free-event", 0).await;
|
|
1452
2335
|
|
|
1453
|
-
let body = json!({"event_slug": "free-event", "name": "Ada", "email": "ada@example.com"})
|
|
1454
|
-
|
|
2336
|
+
let body = json!({"event_slug": "free-event", "name": "Ada", "email": "ada@example.com"})
|
|
2337
|
+
.to_string();
|
|
2338
|
+
let response = registrations_route(
|
|
2339
|
+
&body,
|
|
2340
|
+
&payments::test_platform(),
|
|
2341
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2342
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2343
|
+
)
|
|
2344
|
+
.await;
|
|
1455
2345
|
assert_eq!(response["statusCode"], 422);
|
|
1456
2346
|
assert!(
|
|
1457
2347
|
response["body"].as_str().unwrap().contains("positive"),
|
|
@@ -1462,13 +2352,23 @@ mod tests {
|
|
|
1462
2352
|
// Registration.Request must never have been dispatched at all.
|
|
1463
2353
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1464
2354
|
let instances = read["instances"].as_object().unwrap();
|
|
1465
|
-
assert!(instances
|
|
2355
|
+
assert!(instances
|
|
2356
|
+
.keys()
|
|
2357
|
+
.all(|k| !k.starts_with("CheckoutFixture::Registration#")
|
|
2358
|
+
&& !k.starts_with("Payments::Payment#")));
|
|
1466
2359
|
}
|
|
1467
2360
|
|
|
1468
2361
|
#[tokio::test]
|
|
1469
|
-
async fn registrations_route_runs_the_whole_dispatch_chain_and_returns_a_real_mock_checkout_url(
|
|
2362
|
+
async fn registrations_route_runs_the_whole_dispatch_chain_and_returns_a_real_mock_checkout_url(
|
|
2363
|
+
) {
|
|
1470
2364
|
let client = scratch_db("hecks_host_web_test_registrations_happy_path").await;
|
|
1471
|
-
provision_lineage(
|
|
2365
|
+
provision_lineage(
|
|
2366
|
+
&*client.lock().await,
|
|
2367
|
+
"CheckoutFixture",
|
|
2368
|
+
1,
|
|
2369
|
+
&["Event", "Registration", "Payment"],
|
|
2370
|
+
)
|
|
2371
|
+
.await;
|
|
1472
2372
|
let config = checkout_config(1);
|
|
1473
2373
|
let wasm_path = checkout_wasm_path();
|
|
1474
2374
|
|
|
@@ -1478,7 +2378,13 @@ mod tests {
|
|
|
1478
2378
|
// a misconfiguration — the whole chain runs and returns a real
|
|
1479
2379
|
// checkout URL, never a 500.
|
|
1480
2380
|
let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
|
|
1481
|
-
let response = registrations_route(
|
|
2381
|
+
let response = registrations_route(
|
|
2382
|
+
&body,
|
|
2383
|
+
&payments::test_platform(),
|
|
2384
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2385
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2386
|
+
)
|
|
2387
|
+
.await;
|
|
1482
2388
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1483
2389
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1484
2390
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
@@ -1494,10 +2400,22 @@ mod tests {
|
|
|
1494
2400
|
|
|
1495
2401
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1496
2402
|
let instances = read["instances"].as_object().unwrap();
|
|
1497
|
-
let registration = instances
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
2403
|
+
let registration = instances
|
|
2404
|
+
.iter()
|
|
2405
|
+
.find(|(k, _)| k.starts_with("CheckoutFixture::Registration#"))
|
|
2406
|
+
.map(|(_, v)| v);
|
|
2407
|
+
let payment = instances
|
|
2408
|
+
.iter()
|
|
2409
|
+
.find(|(k, _)| k.starts_with("Payments::Payment#"))
|
|
2410
|
+
.map(|(_, v)| v);
|
|
2411
|
+
assert!(
|
|
2412
|
+
registration.is_some(),
|
|
2413
|
+
"Registration.Request should have committed for real: {instances:?}"
|
|
2414
|
+
);
|
|
2415
|
+
assert!(
|
|
2416
|
+
payment.is_some(),
|
|
2417
|
+
"Payment.Initiate should have committed for real: {instances:?}"
|
|
2418
|
+
);
|
|
1501
2419
|
|
|
1502
2420
|
let registration = registration.unwrap();
|
|
1503
2421
|
let payment = payment.unwrap();
|
|
@@ -1520,7 +2438,13 @@ mod tests {
|
|
|
1520
2438
|
#[tokio::test]
|
|
1521
2439
|
async fn registrations_route_redirects_to_the_caller_s_own_return_to_not_the_event_slug() {
|
|
1522
2440
|
let client = scratch_db("hecks_host_web_test_registrations_return_to").await;
|
|
1523
|
-
provision_lineage(
|
|
2441
|
+
provision_lineage(
|
|
2442
|
+
&*client.lock().await,
|
|
2443
|
+
"CheckoutFixture",
|
|
2444
|
+
1,
|
|
2445
|
+
&["Event", "Registration", "Payment"],
|
|
2446
|
+
)
|
|
2447
|
+
.await;
|
|
1524
2448
|
let config = checkout_config(1);
|
|
1525
2449
|
let wasm_path = checkout_wasm_path();
|
|
1526
2450
|
|
|
@@ -1533,7 +2457,13 @@ mod tests {
|
|
|
1533
2457
|
"return_to": "/sample-studio.html",
|
|
1534
2458
|
})
|
|
1535
2459
|
.to_string();
|
|
1536
|
-
let response = registrations_route(
|
|
2460
|
+
let response = registrations_route(
|
|
2461
|
+
&body,
|
|
2462
|
+
&payments::test_platform(),
|
|
2463
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2464
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2465
|
+
)
|
|
2466
|
+
.await;
|
|
1537
2467
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1538
2468
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1539
2469
|
let reference = body["registration_id"].as_str().unwrap().to_string();
|
|
@@ -1550,7 +2480,13 @@ mod tests {
|
|
|
1550
2480
|
#[tokio::test]
|
|
1551
2481
|
async fn registrations_route_refuses_an_absolute_or_protocol_relative_return_to() {
|
|
1552
2482
|
let client = scratch_db("hecks_host_web_test_registrations_return_to_unsafe").await;
|
|
1553
|
-
provision_lineage(
|
|
2483
|
+
provision_lineage(
|
|
2484
|
+
&*client.lock().await,
|
|
2485
|
+
"CheckoutFixture",
|
|
2486
|
+
1,
|
|
2487
|
+
&["Event", "Registration", "Payment"],
|
|
2488
|
+
)
|
|
2489
|
+
.await;
|
|
1554
2490
|
let config = checkout_config(1);
|
|
1555
2491
|
let wasm_path = checkout_wasm_path();
|
|
1556
2492
|
|
|
@@ -1564,7 +2500,13 @@ mod tests {
|
|
|
1564
2500
|
"return_to": unsafe_return_to,
|
|
1565
2501
|
})
|
|
1566
2502
|
.to_string();
|
|
1567
|
-
let response = registrations_route(
|
|
2503
|
+
let response = registrations_route(
|
|
2504
|
+
&body,
|
|
2505
|
+
&payments::test_platform(),
|
|
2506
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2507
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2508
|
+
)
|
|
2509
|
+
.await;
|
|
1568
2510
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1569
2511
|
let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
1570
2512
|
// checkout_url is the /pay/<id>.html walkthrough page; return_to
|
|
@@ -1572,7 +2514,11 @@ mod tests {
|
|
|
1572
2514
|
// it) so it shows up as "%252F".
|
|
1573
2515
|
let checkout_url = body["checkout_url"].as_str().unwrap();
|
|
1574
2516
|
let parsed = reqwest::Url::parse(checkout_url).unwrap();
|
|
1575
|
-
let success_url = parsed
|
|
2517
|
+
let success_url = parsed
|
|
2518
|
+
.query_pairs()
|
|
2519
|
+
.find(|(k, _)| k == "success_url")
|
|
2520
|
+
.map(|(_, v)| v.into_owned())
|
|
2521
|
+
.unwrap_or_default();
|
|
1576
2522
|
assert!(
|
|
1577
2523
|
success_url.ends_with("return_to=%2F"),
|
|
1578
2524
|
"an unsafe return_to ({unsafe_return_to:?}) must fall back to \"/\": {body:?}"
|
|
@@ -1586,17 +2532,33 @@ mod tests {
|
|
|
1586
2532
|
// checkout_url, then a webhook signed against the same mock secret
|
|
1587
2533
|
// webhook_route falls back to while STRIPE_WEBHOOK_SECRET is unset.
|
|
1588
2534
|
let client = scratch_db("hecks_host_web_test_mock_full_loop").await;
|
|
1589
|
-
provision_lineage(
|
|
2535
|
+
provision_lineage(
|
|
2536
|
+
&*client.lock().await,
|
|
2537
|
+
"CheckoutFixture",
|
|
2538
|
+
1,
|
|
2539
|
+
&["Event", "Registration", "Payment"],
|
|
2540
|
+
)
|
|
2541
|
+
.await;
|
|
1590
2542
|
let config = checkout_config(1);
|
|
1591
2543
|
let wasm_path = checkout_wasm_path();
|
|
1592
2544
|
|
|
1593
2545
|
schedule_event(&client, &wasm_path, &config, "mock-loop-event", 4200).await;
|
|
1594
2546
|
|
|
1595
2547
|
let body = json!({"event_slug": "mock-loop-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
|
|
1596
|
-
let response = registrations_route(
|
|
2548
|
+
let response = registrations_route(
|
|
2549
|
+
&body,
|
|
2550
|
+
&payments::test_platform(),
|
|
2551
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2552
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2553
|
+
)
|
|
2554
|
+
.await;
|
|
1597
2555
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1598
|
-
let response_body: Value =
|
|
1599
|
-
|
|
2556
|
+
let response_body: Value =
|
|
2557
|
+
serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
|
|
2558
|
+
let reference = response_body["registration_id"]
|
|
2559
|
+
.as_str()
|
|
2560
|
+
.unwrap()
|
|
2561
|
+
.to_string();
|
|
1600
2562
|
|
|
1601
2563
|
let secret = MOCK_STRIPE_WEBHOOK_SECRET;
|
|
1602
2564
|
let payload = json!({
|
|
@@ -1606,44 +2568,82 @@ mod tests {
|
|
|
1606
2568
|
let now = now_secs();
|
|
1607
2569
|
let header = sign_stripe_header(secret, now, &payload);
|
|
1608
2570
|
|
|
1609
|
-
let response = webhook_route(
|
|
2571
|
+
let response = webhook_route(
|
|
2572
|
+
&payload,
|
|
2573
|
+
&header,
|
|
2574
|
+
&payments::test_platform(),
|
|
2575
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2576
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2577
|
+
)
|
|
2578
|
+
.await;
|
|
1610
2579
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1611
2580
|
|
|
1612
2581
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
1613
2582
|
let payment = &read["instances"][format!("Payments::Payment#{reference}")];
|
|
1614
2583
|
assert_eq!(payment["status"], "succeeded");
|
|
1615
|
-
assert_eq!(
|
|
2584
|
+
assert_eq!(
|
|
2585
|
+
payment["transaction_id"]["value"],
|
|
2586
|
+
format!("cs_manual_{reference}")
|
|
2587
|
+
);
|
|
1616
2588
|
}
|
|
1617
2589
|
|
|
1618
2590
|
#[tokio::test]
|
|
1619
2591
|
async fn webhook_route_rejects_a_bad_signature_before_touching_the_domain_at_all() {
|
|
1620
2592
|
let client = scratch_db("hecks_host_web_test_webhook_bad_sig").await;
|
|
1621
|
-
provision_lineage(
|
|
2593
|
+
provision_lineage(
|
|
2594
|
+
&*client.lock().await,
|
|
2595
|
+
"CheckoutFixture",
|
|
2596
|
+
1,
|
|
2597
|
+
&["Event", "Registration", "Payment"],
|
|
2598
|
+
)
|
|
2599
|
+
.await;
|
|
1622
2600
|
|
|
1623
|
-
let payload =
|
|
2601
|
+
let payload =
|
|
2602
|
+
json!({"type": "checkout.session.completed", "data": {"object": {}}}).to_string();
|
|
1624
2603
|
let bad_header = "t=1700000000,v1=deadbeef";
|
|
1625
2604
|
|
|
1626
|
-
let response = webhook_route(
|
|
2605
|
+
let response = webhook_route(
|
|
2606
|
+
&payload,
|
|
2607
|
+
bad_header,
|
|
2608
|
+
&payments::test_platform_with_secret("whsec_test_bad_sig"),
|
|
2609
|
+
Host::new(
|
|
2610
|
+
&client,
|
|
2611
|
+
&checkout_wasm_path(),
|
|
2612
|
+
&checkout_config(1),
|
|
2613
|
+
&lambda_client::NeverInvoker,
|
|
2614
|
+
),
|
|
2615
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2616
|
+
)
|
|
2617
|
+
.await;
|
|
1627
2618
|
assert_eq!(response["statusCode"], 400);
|
|
1628
2619
|
}
|
|
1629
2620
|
|
|
1630
2621
|
#[tokio::test]
|
|
1631
2622
|
async fn webhook_route_settles_a_payment_on_checkout_session_completed() {
|
|
1632
2623
|
let client = scratch_db("hecks_host_web_test_webhook_completed").await;
|
|
1633
|
-
provision_lineage(
|
|
2624
|
+
provision_lineage(
|
|
2625
|
+
&*client.lock().await,
|
|
2626
|
+
"CheckoutFixture",
|
|
2627
|
+
1,
|
|
2628
|
+
&["Event", "Registration", "Payment"],
|
|
2629
|
+
)
|
|
2630
|
+
.await;
|
|
1634
2631
|
let config = checkout_config(1);
|
|
1635
2632
|
let wasm_path = checkout_wasm_path();
|
|
1636
2633
|
|
|
1637
2634
|
schedule_event(&client, &wasm_path, &config, "webhook-event", 4200).await;
|
|
1638
2635
|
let initiate = dispatch::handle(
|
|
1639
|
-
&client, &wasm_path,
|
|
2636
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2637
|
+
"Payments::Payment.Initiate",
|
|
1640
2638
|
json!({
|
|
1641
2639
|
"reference": {"value": "REG-WEBHOOK-1"}, "processor": {"value": "stripe"},
|
|
1642
2640
|
"payment_type": {"value": "card"}, "amount": {"cents": 4200},
|
|
1643
2641
|
"client": {"name": "Ada Lovelace", "email": "ada@example.com"},
|
|
1644
2642
|
}),
|
|
1645
|
-
None,
|
|
1646
|
-
)
|
|
2643
|
+
None,
|
|
2644
|
+
)
|
|
2645
|
+
.await
|
|
2646
|
+
.unwrap();
|
|
1647
2647
|
assert!(initiate.accepted, "{:?}", initiate.result);
|
|
1648
2648
|
|
|
1649
2649
|
let secret = "whsec_test_completed";
|
|
@@ -1654,7 +2654,14 @@ mod tests {
|
|
|
1654
2654
|
let now = now_secs();
|
|
1655
2655
|
let header = sign_stripe_header(secret, now, &payload);
|
|
1656
2656
|
|
|
1657
|
-
let response = webhook_route(
|
|
2657
|
+
let response = webhook_route(
|
|
2658
|
+
&payload,
|
|
2659
|
+
&header,
|
|
2660
|
+
&payments::test_platform_with_secret(secret),
|
|
2661
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2662
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2663
|
+
)
|
|
2664
|
+
.await;
|
|
1658
2665
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1659
2666
|
|
|
1660
2667
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
@@ -1665,37 +2672,64 @@ mod tests {
|
|
|
1665
2672
|
// A redelivered webhook (Stripe's delivery is at-least-once) for an
|
|
1666
2673
|
// already-succeeded payment must still answer 200 — the refusal
|
|
1667
2674
|
// underneath is a benign no-op, not surfaced as an error.
|
|
1668
|
-
let redelivered = webhook_route(
|
|
2675
|
+
let redelivered = webhook_route(
|
|
2676
|
+
&payload,
|
|
2677
|
+
&header,
|
|
2678
|
+
&payments::test_platform_with_secret(secret),
|
|
2679
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2680
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2681
|
+
)
|
|
2682
|
+
.await;
|
|
1669
2683
|
assert_eq!(redelivered["statusCode"], 200, "a redelivered webhook must not surface the resulting refusal as an error: {redelivered:?}");
|
|
1670
2684
|
}
|
|
1671
2685
|
|
|
1672
2686
|
#[tokio::test]
|
|
1673
2687
|
async fn webhook_route_declines_a_payment_on_checkout_session_expired() {
|
|
1674
2688
|
let client = scratch_db("hecks_host_web_test_webhook_expired").await;
|
|
1675
|
-
provision_lineage(
|
|
2689
|
+
provision_lineage(
|
|
2690
|
+
&*client.lock().await,
|
|
2691
|
+
"CheckoutFixture",
|
|
2692
|
+
1,
|
|
2693
|
+
&["Event", "Registration", "Payment"],
|
|
2694
|
+
)
|
|
2695
|
+
.await;
|
|
1676
2696
|
let config = checkout_config(1);
|
|
1677
2697
|
let wasm_path = checkout_wasm_path();
|
|
1678
2698
|
|
|
1679
2699
|
schedule_event(&client, &wasm_path, &config, "webhook-event-2", 4200).await;
|
|
1680
2700
|
dispatch::handle(
|
|
1681
|
-
&client, &wasm_path,
|
|
2701
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2702
|
+
"Payments::Payment.Initiate",
|
|
1682
2703
|
json!({
|
|
1683
2704
|
"reference": {"value": "REG-WEBHOOK-2"}, "processor": {"value": "stripe"},
|
|
1684
2705
|
"payment_type": {"value": "card"}, "amount": {"cents": 4200},
|
|
1685
2706
|
"client": {"name": "Ada Lovelace", "email": "ada@example.com"},
|
|
1686
2707
|
}),
|
|
1687
|
-
None,
|
|
1688
|
-
)
|
|
2708
|
+
None,
|
|
2709
|
+
)
|
|
2710
|
+
.await
|
|
2711
|
+
.unwrap()
|
|
2712
|
+
.accepted
|
|
2713
|
+
.then_some(())
|
|
2714
|
+
.expect("initiate should succeed");
|
|
1689
2715
|
|
|
1690
2716
|
let secret = "whsec_test_expired";
|
|
1691
2717
|
let payload = json!({
|
|
1692
2718
|
"type": "checkout.session.expired",
|
|
1693
2719
|
"data": {"object": {"metadata": {"registration_id": "REG-WEBHOOK-2"}}},
|
|
1694
|
-
})
|
|
2720
|
+
})
|
|
2721
|
+
.to_string();
|
|
1695
2722
|
let now = now_secs();
|
|
1696
2723
|
let header = sign_stripe_header(secret, now, &payload);
|
|
1697
2724
|
|
|
1698
|
-
let response = webhook_route(
|
|
2725
|
+
let response = webhook_route(
|
|
2726
|
+
&payload,
|
|
2727
|
+
&header,
|
|
2728
|
+
&payments::test_platform_with_secret(secret),
|
|
2729
|
+
Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
|
|
2730
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2731
|
+
)
|
|
2732
|
+
.await;
|
|
1699
2733
|
assert_eq!(response["statusCode"], 200, "{response:?}");
|
|
1700
2734
|
|
|
1701
2735
|
let read = dispatch::read(&client, &wasm_path).await.unwrap();
|
|
@@ -1707,14 +2741,32 @@ mod tests {
|
|
|
1707
2741
|
#[tokio::test]
|
|
1708
2742
|
async fn webhook_route_ignores_an_event_type_it_does_not_handle_and_still_answers_200() {
|
|
1709
2743
|
let client = scratch_db("hecks_host_web_test_webhook_unhandled_type").await;
|
|
1710
|
-
provision_lineage(
|
|
2744
|
+
provision_lineage(
|
|
2745
|
+
&*client.lock().await,
|
|
2746
|
+
"CheckoutFixture",
|
|
2747
|
+
1,
|
|
2748
|
+
&["Event", "Registration", "Payment"],
|
|
2749
|
+
)
|
|
2750
|
+
.await;
|
|
1711
2751
|
|
|
1712
2752
|
let secret = "whsec_test_unhandled";
|
|
1713
2753
|
let payload = json!({"type": "charge.refunded", "data": {"object": {"metadata": {"registration_id": "whatever"}}}}).to_string();
|
|
1714
2754
|
let now = now_secs();
|
|
1715
2755
|
let header = sign_stripe_header(secret, now, &payload);
|
|
1716
2756
|
|
|
1717
|
-
let response = webhook_route(
|
|
2757
|
+
let response = webhook_route(
|
|
2758
|
+
&payload,
|
|
2759
|
+
&header,
|
|
2760
|
+
&payments::test_platform_with_secret(secret),
|
|
2761
|
+
Host::new(
|
|
2762
|
+
&client,
|
|
2763
|
+
&checkout_wasm_path(),
|
|
2764
|
+
&checkout_config(1),
|
|
2765
|
+
&lambda_client::NeverInvoker,
|
|
2766
|
+
),
|
|
2767
|
+
&crate::commerce_ir::fixture_payments(),
|
|
2768
|
+
)
|
|
2769
|
+
.await;
|
|
1718
2770
|
assert_eq!(response["statusCode"], 200);
|
|
1719
2771
|
}
|
|
1720
2772
|
}
|