pesa-cli 1.0.0__tar.gz → 1.0.2__tar.gz
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.
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/PKG-INFO +6 -1
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/README.md +4 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli/cli.py +34 -52
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli/main.py +0 -2
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli.egg-info/PKG-INFO +6 -1
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli.egg-info/SOURCES.txt +2 -1
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli.egg-info/requires.txt +1 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pyproject.toml +2 -2
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/tests/test_cli.py +0 -1
- pesa_cli-1.0.2/tests/test_cli_e2e.py +128 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/LICENSE +0 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli/__init__.py +0 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli/__main__.py +0 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli.egg-info/dependency_links.txt +0 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli.egg-info/entry_points.txt +0 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/pesa_cli.egg-info/top_level.txt +0 -0
- {pesa_cli-1.0.0 → pesa_cli-1.0.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pesa-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management.
|
|
5
5
|
Author-email: Gabriel Mahia <contact@aikungfu.dev>
|
|
6
6
|
License: CC-BY-NC-ND-4.0
|
|
@@ -22,6 +22,7 @@ Requires-Dist: rich>=13
|
|
|
22
22
|
Provides-Extra: dev
|
|
23
23
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
24
|
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Requires-Dist: daraja-mock>=1.0.0; extra == "dev"
|
|
25
26
|
Dynamic: license-file
|
|
26
27
|
|
|
27
28
|
# pesa-cli
|
|
@@ -182,3 +183,7 @@ if scripting against pesa-cli output is needed.
|
|
|
182
183
|
|
|
183
184
|
*Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
|
|
184
185
|
*Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
|
|
186
|
+
|
|
187
|
+
## Getting sandbox credentials
|
|
188
|
+
|
|
189
|
+
New to Daraja, or on a new machine? Follow [the sandbox setup guide](https://github.com/gabrielmahia/mpesa-python/blob/main/docs/SANDBOX_SETUP.md): it creates a free sandbox app and runs OAuth, an STK Push and a status query, with exact portal labels. Set `DARAJA_CALLBACK_URL` to a public HTTPS address you can watch (for example a webhook.site URL); without it the CLI uses a placeholder and tells you so.
|
|
@@ -156,3 +156,7 @@ if scripting against pesa-cli output is needed.
|
|
|
156
156
|
|
|
157
157
|
*Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
|
|
158
158
|
*Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
|
|
159
|
+
|
|
160
|
+
## Getting sandbox credentials
|
|
161
|
+
|
|
162
|
+
New to Daraja, or on a new machine? Follow [the sandbox setup guide](https://github.com/gabrielmahia/mpesa-python/blob/main/docs/SANDBOX_SETUP.md): it creates a free sandbox app and runs OAuth, an STK Push and a status query, with exact portal labels. Set `DARAJA_CALLBACK_URL` to a public HTTPS address you can watch (for example a webhook.site URL); without it the CLI uses a placeholder and tells you so.
|
|
@@ -26,9 +26,7 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
|
-
import sys
|
|
30
29
|
import time
|
|
31
|
-
import hashlib
|
|
32
30
|
import base64
|
|
33
31
|
import urllib.request
|
|
34
32
|
import urllib.error
|
|
@@ -42,7 +40,6 @@ from rich import print as rprint
|
|
|
42
40
|
from rich.console import Console
|
|
43
41
|
from rich.table import Table
|
|
44
42
|
from rich.panel import Panel
|
|
45
|
-
from rich.text import Text
|
|
46
43
|
|
|
47
44
|
app = typer.Typer(
|
|
48
45
|
name="pesa",
|
|
@@ -107,6 +104,10 @@ def require_credential(key: str, label: str) -> str:
|
|
|
107
104
|
# ── Daraja HTTP layer ──────────────────────────────────────────────────────────
|
|
108
105
|
|
|
109
106
|
def _base_url() -> str:
|
|
107
|
+
# Documented in the README ("Use with daraja-mock"): point the CLI at a local test server instead of Safaricom.
|
|
108
|
+
override = os.environ.get("PESA_BASE_URL")
|
|
109
|
+
if override:
|
|
110
|
+
return override.rstrip("/")
|
|
110
111
|
env = get_credential("environment") or "sandbox"
|
|
111
112
|
if env == "production":
|
|
112
113
|
return "https://api.safaricom.co.ke"
|
|
@@ -186,8 +187,7 @@ def config_show():
|
|
|
186
187
|
display = ("*" * 8 + val[-4:] if val and is_secret and len(val) > 4 else val or "—")
|
|
187
188
|
table.add_row(key, source, display)
|
|
188
189
|
console.print(table)
|
|
189
|
-
rprint(f"
|
|
190
|
-
[dim]Config file: {_config_path()}[/dim]")
|
|
190
|
+
rprint(f"\n[dim]Config file: {_config_path()}[/dim]")
|
|
191
191
|
|
|
192
192
|
|
|
193
193
|
@config_app.command("set")
|
|
@@ -219,12 +219,9 @@ def auth():
|
|
|
219
219
|
raise typer.Exit(1)
|
|
220
220
|
env = get_credential("environment") or "sandbox"
|
|
221
221
|
rprint(Panel(
|
|
222
|
-
f"[green]✓ Authenticated[/green]
|
|
223
|
-
"
|
|
224
|
-
f"
|
|
225
|
-
"
|
|
226
|
-
f"Token: [dim]{token[:12]}...{token[-4:]}[/dim]
|
|
227
|
-
"
|
|
222
|
+
f"[green]✓ Authenticated[/green]\n"
|
|
223
|
+
f"Environment: [cyan]{env}[/cyan]\n"
|
|
224
|
+
f"Token: [dim]{token[:12]}...{token[-4:]}[/dim]\n"
|
|
228
225
|
f"Expires in: [cyan]~60 minutes[/cyan]",
|
|
229
226
|
title="pesa auth", border_style="green",
|
|
230
227
|
))
|
|
@@ -243,7 +240,8 @@ def stk_push(
|
|
|
243
240
|
consumer_secret = require_credential("consumer_secret", "Consumer Secret")
|
|
244
241
|
shortcode = require_credential("shortcode", "Shortcode")
|
|
245
242
|
passkey = require_credential("passkey", "Passkey")
|
|
246
|
-
|
|
243
|
+
callback_given = get_credential("callback_url")
|
|
244
|
+
callback_url = callback_given or "https://example.com/mpesa/callback"
|
|
247
245
|
ref = ref[:12] # Daraja hard limit
|
|
248
246
|
|
|
249
247
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
@@ -271,27 +269,24 @@ def stk_push(
|
|
|
271
269
|
|
|
272
270
|
if response.get("ResponseCode") == "0":
|
|
273
271
|
checkout_id = response.get("CheckoutRequestID", "")
|
|
272
|
+
# Without a callback URL Safaricom posts the result to a placeholder you cannot see; say so instead of failing silently.
|
|
273
|
+
notice = "" if callback_given else (
|
|
274
|
+
"\n\n[yellow]No callback URL set (DARAJA_CALLBACK_URL). Safaricom will post the result to "
|
|
275
|
+
f"{callback_url}, which you cannot see. Use 'pesa stk query' to check the outcome.[/yellow]"
|
|
276
|
+
)
|
|
274
277
|
rprint(Panel(
|
|
275
|
-
f"[green]✓ STK Push sent[/green]
|
|
276
|
-
"
|
|
277
|
-
f"
|
|
278
|
-
"
|
|
279
|
-
f"
|
|
280
|
-
"
|
|
281
|
-
f"
|
|
282
|
-
"
|
|
283
|
-
f"Checkout ID: [dim]{checkout_id}[/dim]
|
|
284
|
-
|
|
285
|
-
"
|
|
286
|
-
f"[dim]The customer will receive a PIN prompt on their phone.[/dim]
|
|
287
|
-
"
|
|
288
|
-
f"[dim]Run: pesa stk query {checkout_id}[/dim]",
|
|
278
|
+
f"[green]✓ STK Push sent[/green]\n"
|
|
279
|
+
f"Phone: [cyan]+{phone}[/cyan]\n"
|
|
280
|
+
f"Amount: [cyan]KES {amount:,}[/cyan]\n"
|
|
281
|
+
f"Reference: [cyan]{ref}[/cyan]\n"
|
|
282
|
+
f"Checkout ID: [dim]{checkout_id}[/dim]\n\n"
|
|
283
|
+
f"[dim]The customer will receive a PIN prompt on their phone.[/dim]\n"
|
|
284
|
+
f"[dim]Run: pesa stk query {checkout_id}[/dim]{notice}",
|
|
289
285
|
title="STK Push", border_style="green",
|
|
290
286
|
))
|
|
291
287
|
else:
|
|
292
288
|
rprint(Panel(
|
|
293
|
-
f"[red]✗ Request rejected[/red]
|
|
294
|
-
{json.dumps(response, indent=2)}",
|
|
289
|
+
f"[red]✗ Request rejected[/red]\n{json.dumps(response, indent=2)}",
|
|
295
290
|
title="STK Push", border_style="red",
|
|
296
291
|
))
|
|
297
292
|
raise typer.Exit(1)
|
|
@@ -329,10 +324,8 @@ def stk_query(
|
|
|
329
324
|
color = STATUS_COLORS.get(str(result_code), "red")
|
|
330
325
|
symbol = "✓" if str(result_code) == "0" else "✗"
|
|
331
326
|
rprint(Panel(
|
|
332
|
-
f"[{color}]{symbol} {result_desc}[/{color}]
|
|
333
|
-
"
|
|
334
|
-
f"Result Code: [cyan]{result_code}[/cyan]
|
|
335
|
-
"
|
|
327
|
+
f"[{color}]{symbol} {result_desc}[/{color}]\n"
|
|
328
|
+
f"Result Code: [cyan]{result_code}[/cyan]\n"
|
|
336
329
|
f"Checkout ID: [dim]{checkout_id}[/dim]",
|
|
337
330
|
title="STK Query", border_style=color,
|
|
338
331
|
))
|
|
@@ -377,22 +370,16 @@ def b2c(
|
|
|
377
370
|
|
|
378
371
|
if response.get("ResponseCode") == "0":
|
|
379
372
|
rprint(Panel(
|
|
380
|
-
f"[green]✓ B2C request accepted[/green]
|
|
381
|
-
"
|
|
382
|
-
f"
|
|
383
|
-
"
|
|
384
|
-
f"Amount: [cyan]KES {amount:,}[/cyan]
|
|
385
|
-
"
|
|
386
|
-
f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]
|
|
387
|
-
|
|
388
|
-
"
|
|
373
|
+
f"[green]✓ B2C request accepted[/green]\n"
|
|
374
|
+
f"Phone: [cyan]+{phone}[/cyan]\n"
|
|
375
|
+
f"Amount: [cyan]KES {amount:,}[/cyan]\n"
|
|
376
|
+
f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]\n\n"
|
|
389
377
|
f"[dim]Result will be posted to your ResultURL.[/dim]",
|
|
390
378
|
title="B2C", border_style="green",
|
|
391
379
|
))
|
|
392
380
|
else:
|
|
393
381
|
rprint(Panel(
|
|
394
|
-
f"[red]✗ B2C rejected[/red]
|
|
395
|
-
{json.dumps(response, indent=2)}",
|
|
382
|
+
f"[red]✗ B2C rejected[/red]\n{json.dumps(response, indent=2)}",
|
|
396
383
|
title="B2C", border_style="red",
|
|
397
384
|
))
|
|
398
385
|
raise typer.Exit(1)
|
|
@@ -428,20 +415,15 @@ def balance():
|
|
|
428
415
|
|
|
429
416
|
if response.get("ResponseCode") == "0":
|
|
430
417
|
rprint(Panel(
|
|
431
|
-
f"[green]✓ Balance request accepted[/green]
|
|
432
|
-
"
|
|
433
|
-
f"
|
|
434
|
-
"
|
|
435
|
-
f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]
|
|
436
|
-
|
|
437
|
-
"
|
|
418
|
+
f"[green]✓ Balance request accepted[/green]\n"
|
|
419
|
+
f"Shortcode: [cyan]{shortcode}[/cyan]\n"
|
|
420
|
+
f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]\n\n"
|
|
438
421
|
f"[dim]Balance will be posted to your ResultURL asynchronously.[/dim]",
|
|
439
422
|
title="Account Balance", border_style="green",
|
|
440
423
|
))
|
|
441
424
|
else:
|
|
442
425
|
rprint(Panel(
|
|
443
|
-
f"[red]✗ Request rejected[/red]
|
|
444
|
-
{json.dumps(response, indent=2)}",
|
|
426
|
+
f"[red]✗ Request rejected[/red]\n{json.dumps(response, indent=2)}",
|
|
445
427
|
title="Account Balance", border_style="red",
|
|
446
428
|
))
|
|
447
429
|
raise typer.Exit(1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pesa-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management.
|
|
5
5
|
Author-email: Gabriel Mahia <contact@aikungfu.dev>
|
|
6
6
|
License: CC-BY-NC-ND-4.0
|
|
@@ -22,6 +22,7 @@ Requires-Dist: rich>=13
|
|
|
22
22
|
Provides-Extra: dev
|
|
23
23
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
24
|
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Requires-Dist: daraja-mock>=1.0.0; extra == "dev"
|
|
25
26
|
Dynamic: license-file
|
|
26
27
|
|
|
27
28
|
# pesa-cli
|
|
@@ -182,3 +183,7 @@ if scripting against pesa-cli output is needed.
|
|
|
182
183
|
|
|
183
184
|
*Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
|
|
184
185
|
*Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
|
|
186
|
+
|
|
187
|
+
## Getting sandbox credentials
|
|
188
|
+
|
|
189
|
+
New to Daraja, or on a new machine? Follow [the sandbox setup guide](https://github.com/gabrielmahia/mpesa-python/blob/main/docs/SANDBOX_SETUP.md): it creates a free sandbox app and runs OAuth, an STK Push and a status query, with exact portal labels. Set `DARAJA_CALLBACK_URL` to a public HTTPS address you can watch (for example a webhook.site URL); without it the CLI uses a placeholder and tells you so.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pesa-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.2"
|
|
8
8
|
description = "Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "CC-BY-NC-ND-4.0"}
|
|
@@ -30,7 +30,7 @@ dependencies = [
|
|
|
30
30
|
pesa = "pesa_cli.cli:app"
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
|
-
dev = ["pytest>=7", "ruff"]
|
|
33
|
+
dev = ["pytest>=7", "ruff", "daraja-mock>=1.0.0"]
|
|
34
34
|
|
|
35
35
|
[project.urls]
|
|
36
36
|
Homepage = "https://github.com/gabrielmahia/pesa-cli"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""End-to-end tests of the CLI that actually ships (`pesa = "pesa_cli.cli:app"`), driven against the daraja-mock test server.
|
|
2
|
+
|
|
3
|
+
Why this exists: pesa_cli/cli.py had a SyntaxError from its first commit, so the published `pesa` command could not start, while the old tests passed because
|
|
4
|
+
they imported a sibling module (pesa_cli.main). These tests exercise the real entry point, every command, and the documented PESA_BASE_URL override."""
|
|
5
|
+
import importlib
|
|
6
|
+
import py_compile
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from typer.testing import CliRunner
|
|
13
|
+
|
|
14
|
+
from daraja_mock import DarajaMock
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
runner = CliRunner()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _free_port() -> int:
|
|
21
|
+
with socket.socket() as s:
|
|
22
|
+
s.bind(("127.0.0.1", 0))
|
|
23
|
+
return s.getsockname()[1]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="module")
|
|
27
|
+
def mock():
|
|
28
|
+
m = DarajaMock(consumer_key="testkey", consumer_secret="testsecret")
|
|
29
|
+
m.base_url = m.run_thread(port=_free_port())
|
|
30
|
+
return m
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def env(mock, tmp_path):
|
|
35
|
+
mock.reset()
|
|
36
|
+
return {
|
|
37
|
+
"PESA_BASE_URL": mock.base_url, "PESA_CONFIG": str(tmp_path / "cfg.json"),
|
|
38
|
+
"DARAJA_CONSUMER_KEY": "testkey", "DARAJA_CONSUMER_SECRET": "testsecret", "DARAJA_SHORTCODE": "174379", "DARAJA_PASSKEY": "testpasskey",
|
|
39
|
+
"DARAJA_INITIATOR_NAME": "testapi", "DARAJA_SECURITY_CREDENTIAL": "testcred",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _app():
|
|
44
|
+
return importlib.import_module("pesa_cli.cli").app
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _posts(mock, path):
|
|
48
|
+
return [e for e in mock.request_log() if e["path"] == path]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── guards against shipping something that cannot start ────────────────────────────────────────────────────────────────
|
|
52
|
+
def test_every_module_in_the_package_compiles():
|
|
53
|
+
for f in sorted((ROOT / "pesa_cli").glob("*.py")):
|
|
54
|
+
py_compile.compile(str(f), doraise=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_declared_entry_point_resolves():
|
|
58
|
+
target = re.search(r'^\s*pesa\s*=\s*"([\w.]+):(\w+)"', (ROOT / "pyproject.toml").read_text(encoding="utf-8"), re.M)
|
|
59
|
+
assert target, "no `pesa` entry point in pyproject.toml"
|
|
60
|
+
assert getattr(importlib.import_module(target.group(1)), target.group(2)) is _app()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_help_lists_every_command():
|
|
64
|
+
out = runner.invoke(_app(), ["--help"]).output
|
|
65
|
+
for cmd in ("auth", "b2c", "balance", "stk", "config"):
|
|
66
|
+
assert cmd in out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── every command against the mock ────────────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
def test_auth(mock, env):
|
|
71
|
+
r = runner.invoke(_app(), ["auth"], env=env)
|
|
72
|
+
assert r.exit_code == 0 and "Authenticated" in r.output
|
|
73
|
+
assert _posts(mock, "/oauth/v1/generate")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_stk_push_sends_the_normalised_payload(mock, env):
|
|
77
|
+
r = runner.invoke(_app(), ["stk", "push", "0712345678", "100", "--ref", "E2E"], env=env)
|
|
78
|
+
assert r.exit_code == 0 and "STK Push sent" in r.output
|
|
79
|
+
body = _posts(mock, "/mpesa/stkpush/v1/processrequest")[-1]["body"]
|
|
80
|
+
assert str(body["PhoneNumber"]) == "254712345678" and str(body["Amount"]) == "100" and body["AccountReference"] == "E2E"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_stk_query_reports_success_and_cancellation(mock, env):
|
|
84
|
+
ok = runner.invoke(_app(), ["stk", "query", "ws_CO_TEST"], env=env)
|
|
85
|
+
assert ok.exit_code == 0 and "Result Code: 0" in ok.output
|
|
86
|
+
mock.set_stk_result(1032)
|
|
87
|
+
cancelled = runner.invoke(_app(), ["stk", "query", "ws_CO_TEST"], env=env)
|
|
88
|
+
assert "Result Code: 1032" in cancelled.output
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_b2c_and_balance_are_accepted(mock, env):
|
|
92
|
+
assert "B2C request accepted" in runner.invoke(_app(), ["b2c", "0712345678", "50"], env=env).output
|
|
93
|
+
assert "Balance request accepted" in runner.invoke(_app(), ["balance"], env=env).output
|
|
94
|
+
assert _posts(mock, "/mpesa/b2c/v3/paymentrequest") and _posts(mock, "/mpesa/accountbalance/v1/query")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_missing_credentials_fail_cleanly(env):
|
|
98
|
+
bare = {k: "" for k in env if k.startswith("DARAJA_")}
|
|
99
|
+
bare.update(PESA_BASE_URL=env["PESA_BASE_URL"], PESA_CONFIG=env["PESA_CONFIG"])
|
|
100
|
+
r = runner.invoke(_app(), ["stk", "push", "0712345678", "100"], env=bare)
|
|
101
|
+
assert r.exit_code == 1 and "Traceback" not in r.output
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize("raw,expected", [("0712345678", "254712345678"), ("+254712345678", "254712345678"), ("712345678", "254712345678"),
|
|
105
|
+
("0112345678", "254112345678"), ("+254110123456", "254110123456")])
|
|
106
|
+
def test_phone_normalisation(raw, expected):
|
|
107
|
+
assert importlib.import_module("pesa_cli.cli")._normalise_phone(raw) == expected
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_invalid_phone_is_rejected():
|
|
111
|
+
import typer
|
|
112
|
+
|
|
113
|
+
with pytest.raises(typer.Exit):
|
|
114
|
+
importlib.import_module("pesa_cli.cli")._normalise_phone("12345")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_a_missing_callback_url_is_announced_not_silent(mock, env):
|
|
118
|
+
"""Without DARAJA_CALLBACK_URL the CLI substitutes https://example.com/mpesa/callback, so the user never sees the result and had no hint why."""
|
|
119
|
+
r = runner.invoke(_app(), ["stk", "push", "0712345678", "10", "--ref", "NOCB"], env=env)
|
|
120
|
+
assert r.exit_code == 0 and "No callback URL" in r.output
|
|
121
|
+
body = _posts(mock, "/mpesa/stkpush/v1/processrequest")[-1]["body"]
|
|
122
|
+
assert body["CallBackURL"] == "https://example.com/mpesa/callback"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_a_configured_callback_url_is_used_and_not_warned_about(mock, env):
|
|
126
|
+
r = runner.invoke(_app(), ["stk", "push", "0712345678", "10", "--ref", "WITHCB"], env={**env, "DARAJA_CALLBACK_URL": "https://webhook.site/abc"})
|
|
127
|
+
assert r.exit_code == 0 and "No callback URL" not in r.output
|
|
128
|
+
assert _posts(mock, "/mpesa/stkpush/v1/processrequest")[-1]["body"]["CallBackURL"] == "https://webhook.site/abc"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|