diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbaece..b9df802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes ## Unreleased +- `webhooks test WEBHOOK_ID` without `--data` sends a test event, with a new `delivery_id` and a payload of type `test` and event `created`. Before, it failed with "This operation requires --data". `--data` still sends your own payload. - A list that returns every item in one response, such as `wallets list`, accepts `--all` and ignores it, because there are no more pages. Before, it rejected `--all`. `--all` still pages through lists that take `--limit`. - A command that acts on one wallet, such as `wallets get`, also takes the wallet as `--wallet ID`. If the argument and `--wallet` name different wallets, the command stops. Without either, the error names both ways to pass it. A profile's or `VOLTAGE_WALLET_ID`'s wallet is never used, so a delete always names its wallet. - A request that gets no response says why: a timeout (for the auth service, its fixed 30-second limit, which `--timeout` does not change), a connection not made within the 10-second connect limit (which `--timeout` does not change), a failed TLS connection, a connection that could not be made, or one that closed early. A hint names the next step. A write that timed out or closed early may have been applied, so its hint says to look up the resource ID before running the command again, never to retry. This covers API, auth, price, and checkout stream requests, which before said only that the request failed. diff --git a/docs/guides/webhooks.md b/docs/guides/webhooks.md index 1c5ee39..0a3824f 100644 --- a/docs/guides/webhooks.md +++ b/docs/guides/webhooks.md @@ -34,7 +34,13 @@ voltage webhooks update WEBHOOK_ID --profile prod \ ## Test, pause, and resume -A test delivery takes a body with a new `delivery_id` and a sample event `payload`. The smallest is a `test.created` event, for example in `test-delivery.json`: +Without `--data`, `webhooks test` sends a `test.created` event with a new `delivery_id`: + +```sh +voltage webhooks test WEBHOOK_ID --profile prod --execute +``` + +To send your own delivery, pass a body with a new `delivery_id` and a sample event `payload` with `--data`, for example in `test-delivery.json`: ```json { diff --git a/src/cli.rs b/src/cli.rs index 9a4be4c..a6915c0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -546,6 +546,8 @@ pub enum FriendlyFlags { Quote(QuoteFlags), CreateWebhook(WebhookFlags), UpdateWebhook(UpdateWebhookFlags), + /// `webhooks test` without `--data` sends the contract's test event. + TestWebhook, } impl FriendlyFlags { @@ -556,7 +558,7 @@ impl FriendlyFlags { Self::Payment(flags) => flags.id.is_none(), Self::Quote(flags) => flags.id.is_none(), Self::CreateWebhook(flags) => flags.id.is_none(), - Self::UpdateWallet(_) | Self::UpdateWebhook(_) => false, + Self::UpdateWallet(_) | Self::UpdateWebhook(_) | Self::TestWebhook => false, } } } @@ -832,6 +834,7 @@ fn friendly_flags(id: OperationId, m: &ArgMatches) -> Result FriendlyFlags::Quote(parse(m)?), OperationId::CreateWebhook => FriendlyFlags::CreateWebhook(parse(m)?), OperationId::UpdateWebhook => FriendlyFlags::UpdateWebhook(parse(m)?), + OperationId::TestWebhook => FriendlyFlags::TestWebhook, _ => return Ok(None), })) } @@ -1079,6 +1082,9 @@ fn friendly_requirements(id: OperationId, command_name: &str) -> Option<&'static (OperationId::CreateWallet, "create") => Some( "Select one environment with --env or --profile, then pass --name, --credit-line, --network, and --limit.", ), + (OperationId::TestWebhook, _) => Some( + "Without --data, sends a test event: a new delivery_id and a payload of type test with event created.", + ), _ => None, } } @@ -1155,7 +1161,10 @@ fn endpoint_examples(id: OperationId, command_name: &str) -> Option<&'static str voltage webhooks keys rotate WEBHOOK_ID --profile prod --output-file webhook-key.json --execute" } (OperationId::TestWebhook, _) => { - " # Send a test delivery with a delivery_id and a sample payload + " # Send the test event + voltage webhooks test WEBHOOK_ID --profile prod --execute + + # Send your own delivery_id and payload voltage webhooks test WEBHOOK_ID --profile prod --data @test-delivery.json --execute" } (OperationId::StartWebhookAsUser, _) => { diff --git a/src/input.rs b/src/input.rs index 02d6d75..fce8488 100644 --- a/src/input.rs +++ b/src/input.rs @@ -354,6 +354,41 @@ struct UpdateWebhookBody { events: Vec>, } +/// The contract's test delivery: `payload` of type `test`, whose only event is `created`. +#[derive(Serialize)] +struct TestWebhookBody { + delivery_id: Uuid, + payload: TestPayload, +} + +#[derive(Serialize)] +struct TestPayload { + #[serde(rename = "type")] + kind: &'static str, + detail: TestDetail, +} + +#[derive(Serialize)] +struct TestDetail { + event: &'static str, + data: &'static str, +} + +impl TestWebhookBody { + fn new() -> Self { + Self { + delivery_id: Uuid::new_v4(), + payload: TestPayload { + kind: "test", + detail: TestDetail { + event: "created", + data: "Test delivery from voltage webhooks test", + }, + }, + } + } +} + fn create_wallet(flags: &WalletFlags, scope: &Scope) -> Result { Ok(CreateWalletBody { id: new_id(flags.id), @@ -592,6 +627,7 @@ fn friendly_body( events: events(event)?, })? } + FriendlyFlags::TestWebhook => serde_json::to_value(TestWebhookBody::new())?, }) } @@ -1263,11 +1299,25 @@ mod tests { assert!(hint.contains("--unit usd --to btc")); } assert_eq!( - request_error(&["webhooks", "test", WALLET]), + request_error(&["payments", "check"]), "This operation requires --data @file or --data -" ); } + #[test] + fn webhook_test_without_data_sends_the_contract_test_event() { + let first = build(&["webhooks", "test", WALLET]).unwrap(); + let body = first.body.unwrap(); + assert_eq!( + body["payload"], + json!({"type": "test", "detail": {"event": "created", "data": "Test delivery from voltage webhooks test"}}) + ); + let delivery: Uuid = serde_json::from_value(body["delivery_id"].clone()).unwrap(); + // Each test gets its own delivery ID. + let second = build(&["webhooks", "test", WALLET]).unwrap().body.unwrap(); + assert_ne!(second["delivery_id"], json!(delivery)); + } + #[test] fn raw_payloads_must_agree_with_scope_and_carry_payment_identity() { let check = |id: OperationId, alias: Option, body: Value| { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 16ad741..953a677 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2557,6 +2557,38 @@ async fn a_described_change_sends_nothing_records_nothing_and_omits_private_valu assert!(server.received_requests().await.unwrap().is_empty()); } +#[tokio::test] +async fn webhook_test_without_data_sends_the_test_event() { + let (server, dir) = fixture().await; + let test_path = format!("/organizations/{ORG}/environments/{ENV}/webhooks/{RESOURCE}/test"); + respond_once(&server, route("POST", test_path), accepted()).await; + let sent = cli(dir.path(), &server) + .args([ + "webhooks", + "test", + RESOURCE, + "--org", + ORG, + "--env", + ENV, + "--execute", + ]) + .assert() + .success(); + assert_eq!(json_stdout(&sent)["http_status"], 202); + let requests = server.received_requests().await.unwrap(); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["payload"]["type"], "test"); + assert_eq!(body["payload"]["detail"]["event"], "created"); + assert!( + body["delivery_id"] + .as_str() + .unwrap() + .parse::() + .is_ok() + ); +} + #[test] fn requests_without_a_response_say_why_and_name_the_next_step() { let dir = private_tempdir();