diff --git a/src/goodeye_cli/app.py b/src/goodeye_cli/app.py index ff54c20..1b925b8 100644 --- a/src/goodeye_cli/app.py +++ b/src/goodeye_cli/app.py @@ -22,6 +22,7 @@ from goodeye_cli.commands import login as login_cmd from goodeye_cli.commands import logout as logout_cmd from goodeye_cli.commands import me as me_cmds +from goodeye_cli.commands import referrals as referrals_cmds from goodeye_cli.commands import register as register_cmd from goodeye_cli.commands import teams as teams_cmds from goodeye_cli.commands import templates as templates_cmds @@ -65,6 +66,7 @@ help="Deploy and run image generators.", ) app.add_typer(invitations_cmds.app, name="invitations", help="Manage invitations.") +app.add_typer(referrals_cmds.app, name="referrals", help="View and redeem referral codes.") def _version_callback(value: bool) -> None: diff --git a/src/goodeye_cli/client.py b/src/goodeye_cli/client.py index cda731a..c1f2f0a 100644 --- a/src/goodeye_cli/client.py +++ b/src/goodeye_cli/client.py @@ -38,6 +38,8 @@ InvitationDeclineResult, InvitationList, MeResponse, + RedeemResponse, + ReferralStatusResponse, RenameHandleResult, SafetyCheckResult, TeamCreated, @@ -233,6 +235,14 @@ def get_usage(self) -> UsageResponse: response = self._request("GET", "/v1/me/usage") return UsageResponse.model_validate(response.json()) + def get_referral_status(self) -> ReferralStatusResponse: + response = self._request("GET", "/v1/referrals/me") + return ReferralStatusResponse.model_validate(response.json()) + + def redeem_referral_code(self, code: str) -> RedeemResponse: + response = self._request("POST", "/v1/referrals/redeem", json_body={"code": code}) + return RedeemResponse.model_validate(response.json()) + def claim_handle(self, handle: str) -> ClaimHandleResult: response = self._request("PATCH", "/v1/me", json_body={"handle": handle}) return ClaimHandleResult.model_validate(response.json()) diff --git a/src/goodeye_cli/commands/login.py b/src/goodeye_cli/commands/login.py index 8fdf799..9066213 100644 --- a/src/goodeye_cli/commands/login.py +++ b/src/goodeye_cli/commands/login.py @@ -9,6 +9,7 @@ from goodeye_cli.auth_flows import device_code_login from goodeye_cli.client import GoodeyeClient +from goodeye_cli.commands.referrals import _maybe_redeem_referral from goodeye_cli.config import get_server, save_client_config, save_credentials @@ -22,6 +23,11 @@ def login( "Use `goodeye login-verify --email --code ` to finish." ), ), + referral_code: str | None = typer.Option( + None, + "--referral-code", + help="Referral code to claim a bonus after signing in.", + ), ) -> None: """Sign in to Goodeye on this machine. @@ -38,7 +44,8 @@ def login( console.print("Check your email for next steps.") console.print( f"[dim]Non-interactive login started. Finish with: " - f"goodeye login-verify --email {email} --code [/dim]" + f"goodeye login-verify --email {email} --code " + f" (add --referral-code to claim a referral bonus)[/dim]" ) return @@ -57,6 +64,7 @@ def login( path = save_credentials({"api_key": api_key, "server": server}) console.print(f"[green]Signed in.[/green] Credentials saved to {path}") + _maybe_redeem_referral(server, api_key, referral_code, console) def login_verify( @@ -72,6 +80,11 @@ def login_verify( "-c", help="One-time code sent to your email.", ), + referral_code: str | None = typer.Option( + None, + "--referral-code", + help="Referral code to claim a bonus after signing in.", + ), ) -> None: """Complete non-interactive email-code login and save local credentials.""" console = Console() @@ -80,3 +93,4 @@ def login_verify( result = client.login_verify(email, code) path = save_credentials({"api_key": result.api_key, "server": server}) console.print(f"[green]Signed in.[/green] Credentials saved to {path}") + _maybe_redeem_referral(server, result.api_key, referral_code, console) diff --git a/src/goodeye_cli/commands/referrals.py b/src/goodeye_cli/commands/referrals.py new file mode 100644 index 0000000..3661377 --- /dev/null +++ b/src/goodeye_cli/commands/referrals.py @@ -0,0 +1,121 @@ +"""`goodeye referrals ...` subcommand group. + +Covers viewing your referral code and redeeming a referral from another user. +""" + +from __future__ import annotations + +import json as _json + +import typer +from rich.console import Console + +from goodeye_cli.client import GoodeyeClient +from goodeye_cli.config import get_api_key, get_server +from goodeye_cli.errors import AuthRequired, GoodeyeError + +app = typer.Typer(help="View and redeem referral codes.", no_args_is_help=True) + + +def _maybe_redeem_referral( + server: str, api_key: str, referral_code: str | None, console: Console +) -> None: + """Redeem a referral code after authentication, if one was supplied. + + Non-fatal: a redeem failure prints a warning but does not abort the + sign-in flow. The caller should invoke this only after credentials are + already saved. + """ + if not referral_code: + return + try: + with GoodeyeClient(server, api_key=api_key) as client: + result = client.redeem_referral_code(referral_code) + except GoodeyeError as err: + console.print(f"[yellow]Referral code not applied:[/yellow] {err.message}") + if err.hint: + console.print(f"[dim]{err.hint}[/dim]") + return + console.print( + f"[green]Referral bonus applied:[/green] ${result.credits_granted_usd} in credits." + ) + + +def _require_client() -> GoodeyeClient: + api_key = get_api_key() + if not api_key: + raise AuthRequired( + slug="auth_required", + message="Authentication required.", + hint="Run `goodeye login` or set GOODEYE_API_KEY.", + ) + return GoodeyeClient(get_server(), api_key=api_key) + + +@app.command("status") +def status( + json_output: bool = typer.Option(False, "--json", help="Print results as JSON."), +) -> None: + """Show your referral code and how many people have used it. + + Prints your personal referral code, instructions for sharing it, how many + people have redeemed it, how many have activated their account, and how much + you have earned so far. + """ + console = Console() + with _require_client() as client: + result = client.get_referral_status() + + if json_output: + payload = { + "code": result.code, + "instructions": result.instructions, + "redeemed_count": result.redeemed_count, + "activated_count": result.activated_count, + "credits_earned_usd": result.credits_earned_usd, + "slots_remaining": result.slots_remaining, + } + typer.echo(_json.dumps(payload)) + return + + console.print(f"Your referral code: [bold]{result.code}[/bold]") + console.print(f"Instructions: {result.instructions}") + console.print(f"Redeemed: {result.redeemed_count}") + console.print(f"Activated: {result.activated_count}") + console.print(f"Credits earned: ${result.credits_earned_usd}") + console.print(f"Slots remaining: {result.slots_remaining}") + + +@app.command("redeem") +def redeem( + code: str = typer.Argument(..., help="Referral code to redeem."), + json_output: bool = typer.Option(False, "--json", help="Print results as JSON."), +) -> None: + """Redeem a referral code from another Goodeye user. + + Applies the referral to your account. Your bonus credits land as soon as you + redeem a valid code. The referrer's reward is separate: it is paid later, once + your account is activated. + """ + console = Console() + with _require_client() as client: + result = client.redeem_referral_code(code) + + if json_output: + payload = { + "status": result.status, + "credits_granted_usd": result.credits_granted_usd, + "expires_at": result.expires_at.isoformat(), + "referrer_handle": result.referrer_handle, + } + typer.echo(_json.dumps(payload)) + return + + console.print("[green]Referral redeemed[/green]") + console.print(f"Credits granted: ${result.credits_granted_usd}") + if result.referrer_handle: + console.print(f"Referred by: @{result.referrer_handle}") + console.print(f"Expires at: {result.expires_at.isoformat()}") + + +__all__ = ["_maybe_redeem_referral", "app", "redeem", "status"] diff --git a/src/goodeye_cli/commands/register.py b/src/goodeye_cli/commands/register.py index 3cf97d3..d4241b4 100644 --- a/src/goodeye_cli/commands/register.py +++ b/src/goodeye_cli/commands/register.py @@ -6,6 +6,7 @@ from rich.console import Console from goodeye_cli.client import GoodeyeClient +from goodeye_cli.commands.referrals import _maybe_redeem_referral from goodeye_cli.config import get_server, save_credentials @@ -33,7 +34,8 @@ def register( console.print("Check your email for next steps.") console.print( f"[dim]Non-interactive registration started. Finish with: " - f"goodeye register-verify --email {email} --code [/dim]" + f"goodeye register-verify --email {email} --code " + f" (add --referral-code to claim a referral bonus)[/dim]" ) @@ -50,6 +52,11 @@ def register_verify( "-c", help="One-time code sent to your email.", ), + referral_code: str | None = typer.Option( + None, + "--referral-code", + help="Referral code to claim a bonus after registering.", + ), ) -> None: """Complete non-interactive registration and save local credentials.""" console = Console() @@ -58,3 +65,4 @@ def register_verify( result = client.register_verify(email, code) path = save_credentials({"api_key": result.api_key, "server": server}) console.print(f"[green]Account registered.[/green] Credentials saved to {path}") + _maybe_redeem_referral(server, result.api_key, referral_code, console) diff --git a/src/goodeye_cli/errors.py b/src/goodeye_cli/errors.py index 1f0d533..19d4f2d 100644 --- a/src/goodeye_cli/errors.py +++ b/src/goodeye_cli/errors.py @@ -128,6 +128,10 @@ class SafetyVerificationUnavailable(GoodeyeError): "anonymous_daily_cap": AnonymousDailyCapReached, "safety_verification_failed": SafetyVerificationFailed, "safety_verification_unavailable": SafetyVerificationUnavailable, + "referral_code_not_found": NotFound, + "self_referral": Conflict, + "already_referred": Conflict, + "referral_not_eligible": Conflict, } diff --git a/src/goodeye_cli/wire.py b/src/goodeye_cli/wire.py index 0d02ca9..b21394f 100644 --- a/src/goodeye_cli/wire.py +++ b/src/goodeye_cli/wire.py @@ -776,3 +776,26 @@ class ImageGenerationRunResult(_WireBase): created_at: str error_code: str | None = None error_message: str | None = None + + +# ----- referrals ----- + + +class ReferralStatusResponse(_WireBase): + """Response from GET /v1/referrals/me.""" + + code: str + instructions: str + redeemed_count: int + activated_count: int + credits_earned_usd: str + slots_remaining: int + + +class RedeemResponse(_WireBase): + """Response from POST /v1/referrals/redeem.""" + + status: str + credits_granted_usd: str + expires_at: datetime + referrer_handle: str diff --git a/tests/test_commands_login_register_referral.py b/tests/test_commands_login_register_referral.py new file mode 100644 index 0000000..2e80e1d --- /dev/null +++ b/tests/test_commands_login_register_referral.py @@ -0,0 +1,473 @@ +"""Tests for --referral-code on register-verify, login (device-code), and login-verify. + +Verifies: +- Successful redeem after each auth completion command. +- Flag omitted means redeem is NOT called. +- Non-fatal failure: command exits 0, credentials are saved, and the error + message is printed. +- Initiation hint lines (register / login --email) mention --referral-code. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import httpx +import respx +from typer.testing import CliRunner + +from goodeye_cli.app import app +from goodeye_cli.config import ConfigPaths, load_credentials + +SERVER = "https://example.test" +DEVICE_URI = "https://api.workos.com/user_management/authorize/device" +TOKEN_URI = "https://api.workos.com/user_management/authenticate" + +_REDEEM_BODY = { + "status": "pending", + "credits_granted_usd": "5.00", + "expires_at": "2026-09-15T00:00:00+00:00", + "referrer_handle": "alice", +} + +_CLIENT_CONFIG_BODY = { + "workos_client_id": "client_X", + "workos_device_authorization_uri": DEVICE_URI, + "workos_token_uri": TOKEN_URI, +} + + +def _env(monkeypatch, tmp_config_paths: ConfigPaths, *, api_key: str | None = None) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_config_paths.config_dir.parent)) + monkeypatch.setenv("GOODEYE_SERVER", SERVER) + if api_key is not None: + monkeypatch.setenv("GOODEYE_API_KEY", api_key) + else: + monkeypatch.delenv("GOODEYE_API_KEY", raising=False) + + +# --------------------------------------------------------------------------- +# register-verify --referral-code +# --------------------------------------------------------------------------- + + +@respx.mock +def test_register_verify_with_referral_code_success( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """register-verify redeems the code and prints success; credentials are saved.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/register/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_register"}) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke( + app, + [ + "register-verify", + "--email", + "e@x.com", + "--code", + "123456", + "--referral-code", + "GOODEYE-REF1", + ], + ) + + assert result.exit_code == 0, result.output + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_register" + assert "Referral bonus applied" in result.output + assert "$5.00" in result.output + + +@respx.mock +def test_register_verify_without_referral_code_does_not_call_redeem( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """register-verify without --referral-code must not call the redeem endpoint.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/register/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_register2"}) + ) + redeem_route = respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke( + app, + ["register-verify", "--email", "e@x.com", "--code", "123456"], + ) + + assert result.exit_code == 0, result.output + assert redeem_route.call_count == 0 + assert "Referral" not in result.output + + +@respx.mock +def test_register_verify_referral_failure_is_nonfatal( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """When redeem fails (already_referred), register-verify still exits 0 and saves creds.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/register/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_register3"}) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={ + "error": "already_referred", + "message": "You have already used a referral code.", + "hint": "Each account may only redeem one referral code.", + }, + ) + ) + + runner = CliRunner() + result = runner.invoke( + app, + [ + "register-verify", + "--email", + "e@x.com", + "--code", + "123456", + "--referral-code", + "GOODEYE-USED", + ], + ) + + assert result.exit_code == 0, result.output + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_register3" + assert "Referral code not applied" in result.output + assert "already used a referral code" in result.output + + +@respx.mock +def test_register_verify_referral_not_eligible_is_nonfatal( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """referral_not_eligible is non-fatal; exit 0 and creds saved.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/register/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_register4"}) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={ + "error": "referral_not_eligible", + "message": "This referral code is no longer eligible.", + }, + ) + ) + + runner = CliRunner() + result = runner.invoke( + app, + [ + "register-verify", + "--email", + "e@x.com", + "--code", + "123456", + "--referral-code", + "GOODEYE-EXPIRED", + ], + ) + + assert result.exit_code == 0, result.output + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_register4" + assert "Referral code not applied" in result.output + + +# --------------------------------------------------------------------------- +# login --referral-code (interactive device-code path) +# --------------------------------------------------------------------------- + + +def test_login_device_code_with_referral_code_success( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Interactive login redeems the referral code after device-code auth succeeds.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_login1", + ) as mock_dcl, + patch("goodeye_cli.commands.login.save_client_config"), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login", "--referral-code", "GOODEYE-REF2"]) + + assert result.exit_code == 0, result.output + assert mock_dcl.called + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_login1" + assert "Referral bonus applied" in result.output + assert "$5.00" in result.output + + +def test_login_device_code_without_referral_code_does_not_call_redeem( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """Interactive login without --referral-code must not call the redeem endpoint.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_login2", + ), + patch("goodeye_cli.commands.login.save_client_config"), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + redeem_route = respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login"]) + + assert result.exit_code == 0, result.output + assert redeem_route.call_count == 0 + assert "Referral" not in result.output + + +def test_login_device_code_referral_failure_is_nonfatal( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """When redeem fails, interactive login still exits 0 and credentials are saved.""" + _env(monkeypatch, tmp_config_paths) + + with ( + respx.mock, + patch( + "goodeye_cli.commands.login.device_code_login", + return_value="good_live_EXAMPLE_login3", + ), + patch("goodeye_cli.commands.login.save_client_config"), + ): + respx.get(f"{SERVER}/.well-known/goodeye-client-config").mock( + return_value=httpx.Response(200, json=_CLIENT_CONFIG_BODY) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={ + "error": "already_referred", + "message": "You have already used a referral code.", + }, + ) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login", "--referral-code", "GOODEYE-USED2"]) + + assert result.exit_code == 0, result.output + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_login3" + assert "Referral code not applied" in result.output + assert "already used a referral code" in result.output + + +# --------------------------------------------------------------------------- +# login-verify --referral-code +# --------------------------------------------------------------------------- + + +@respx.mock +def test_login_verify_with_referral_code_success( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """login-verify redeems the referral code after email-code auth completes.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/login/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_lv1"}) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke( + app, + [ + "login-verify", + "--email", + "e@x.com", + "--code", + "654321", + "--referral-code", + "GOODEYE-REF3", + ], + ) + + assert result.exit_code == 0, result.output + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_lv1" + assert "Referral bonus applied" in result.output + assert "$5.00" in result.output + + +@respx.mock +def test_login_verify_without_referral_code_does_not_call_redeem( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """login-verify without --referral-code must not call the redeem endpoint.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/login/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_lv2"}) + ) + redeem_route = respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + + runner = CliRunner() + result = runner.invoke( + app, + ["login-verify", "--email", "e@x.com", "--code", "654321"], + ) + + assert result.exit_code == 0, result.output + assert redeem_route.call_count == 0 + assert "Referral" not in result.output + + +@respx.mock +def test_login_verify_referral_failure_is_nonfatal( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """When redeem fails, login-verify still exits 0 and credentials are saved.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/login/verify").mock( + return_value=httpx.Response(200, json={"api_key": "good_live_EXAMPLE_lv3"}) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={ + "error": "already_referred", + "message": "You have already used a referral code.", + "hint": "Each account may only redeem one referral code.", + }, + ) + ) + + runner = CliRunner() + result = runner.invoke( + app, + [ + "login-verify", + "--email", + "e@x.com", + "--code", + "654321", + "--referral-code", + "GOODEYE-USED3", + ], + ) + + assert result.exit_code == 0, result.output + creds = load_credentials(tmp_config_paths) + assert creds is not None + assert creds["api_key"] == "good_live_EXAMPLE_lv3" + assert "Referral code not applied" in result.output + assert "already used a referral code" in result.output + assert "Each account may only redeem one referral code" in result.output + + +# --------------------------------------------------------------------------- +# Initiation hint mentions --referral-code +# --------------------------------------------------------------------------- + + +@respx.mock +def test_register_initiation_hint_mentions_referral_code( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """register prints a dim line mentioning --referral-code for the verify step.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/register").mock( + return_value=httpx.Response(202, json={"status": "ok", "message": "Check your email."}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["register", "--email", "e@x.com"]) + + assert result.exit_code == 0, result.output + assert "--referral-code" in result.output + + +@respx.mock +def test_login_email_initiation_hint_mentions_referral_code( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """login --email prints a dim line mentioning --referral-code for login-verify.""" + _env(monkeypatch, tmp_config_paths) + respx.post(f"{SERVER}/v1/login").mock( + return_value=httpx.Response(202, json={"status": "ok", "message": "Check your email."}) + ) + + runner = CliRunner() + result = runner.invoke(app, ["login", "--email", "e@x.com"]) + + assert result.exit_code == 0, result.output + assert "--referral-code" in result.output + + +# --------------------------------------------------------------------------- +# No em dashes in modified source files +# --------------------------------------------------------------------------- + + +def test_login_commands_no_em_dash() -> None: + from pathlib import Path + + src = Path(__file__).resolve().parent.parent / "src" / "goodeye_cli" / "commands" / "login.py" + assert "—" not in src.read_text() + + +def test_register_commands_no_em_dash() -> None: + from pathlib import Path + + src = ( + Path(__file__).resolve().parent.parent / "src" / "goodeye_cli" / "commands" / "register.py" + ) + assert "—" not in src.read_text() + + +def test_referrals_helper_no_em_dash() -> None: + from pathlib import Path + + src = ( + Path(__file__).resolve().parent.parent / "src" / "goodeye_cli" / "commands" / "referrals.py" + ) + assert "—" not in src.read_text() diff --git a/tests/test_commands_referrals.py b/tests/test_commands_referrals.py new file mode 100644 index 0000000..4b82098 --- /dev/null +++ b/tests/test_commands_referrals.py @@ -0,0 +1,300 @@ +"""Tests for the `goodeye referrals ...` subcommand group. + +Covers happy-path status and redeem, --json output, all four error slugs, +and missing credentials. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx +from typer.testing import CliRunner + +from goodeye_cli.app import app +from goodeye_cli.config import ConfigPaths +from goodeye_cli.errors import ( + AuthRequired, + Conflict, + NotFound, + error_from_body, +) + +SERVER = "https://example.test" + +_STATUS_BODY = { + "code": "GOODEYE-ABC123", + "instructions": "Share this code with a friend to earn credits.", + "redeemed_count": 2, + "activated_count": 1, + "credits_earned_usd": "5.00", + "slots_remaining": 8, +} + +_REDEEM_BODY = { + "status": "pending", + "credits_granted_usd": "5.00", + "expires_at": "2026-09-15T00:00:00+00:00", + "referrer_handle": "alice", +} + + +def _env(monkeypatch, tmp_config_paths: ConfigPaths, *, api_key: str | None) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_config_paths.config_dir.parent)) + monkeypatch.setenv("GOODEYE_SERVER", SERVER) + if api_key is not None: + monkeypatch.setenv("GOODEYE_API_KEY", api_key) + else: + monkeypatch.delenv("GOODEYE_API_KEY", raising=False) + + +# ----- referral status ----- + + +@respx.mock +def test_referral_status_human(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.get(f"{SERVER}/v1/referrals/me").mock(return_value=httpx.Response(200, json=_STATUS_BODY)) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "status"]) + assert result.exit_code == 0, result.output + assert "GOODEYE-ABC123" in result.output + assert "Share this code" in result.output + assert "Redeemed: 2" in result.output + assert "Activated: 1" in result.output + assert "$5.00" in result.output + assert "Slots remaining: 8" in result.output + + +@respx.mock +def test_referral_status_json(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.get(f"{SERVER}/v1/referrals/me").mock(return_value=httpx.Response(200, json=_STATUS_BODY)) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "status", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output.strip()) + assert data["code"] == "GOODEYE-ABC123" + assert data["redeemed_count"] == 2 + assert data["activated_count"] == 1 + assert data["credits_earned_usd"] == "5.00" + assert data["slots_remaining"] == 8 + assert "instructions" in data + + +def test_referral_status_no_credentials(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + _env(monkeypatch, tmp_config_paths, api_key=None) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "status"]) + assert result.exit_code != 0 + assert isinstance(result.exception, AuthRequired) + + +# ----- referral redeem ----- + + +@respx.mock +def test_referral_redeem_human(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123"]) + assert result.exit_code == 0, result.output + assert "redeemed" in result.output.lower() + assert "$5.00" in result.output + assert "@alice" in result.output + # The raw status value is JSON-only and must not leak into human output. + assert "pending" not in result.output + + +@respx.mock +def test_referral_redeem_human_blank_referrer_handle( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + """When the server returns an empty referrer_handle, do not print a bare `@`.""" + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + body = {**_REDEEM_BODY, "referrer_handle": ""} + respx.post(f"{SERVER}/v1/referrals/redeem").mock(return_value=httpx.Response(200, json=body)) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123"]) + assert result.exit_code == 0, result.output + assert "redeemed" in result.output.lower() + assert "$5.00" in result.output + assert "Referred by:" not in result.output + assert "@" not in result.output + + +@respx.mock +def test_referral_redeem_json(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response(200, json=_REDEEM_BODY) + ) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output.strip()) + assert data["status"] == "pending" + assert data["credits_granted_usd"] == "5.00" + assert data["referrer_handle"] == "alice" + assert "expires_at" in data + + +def test_referral_redeem_no_credentials(tmp_config_paths: ConfigPaths, monkeypatch) -> None: + _env(monkeypatch, tmp_config_paths, api_key=None) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123"]) + assert result.exit_code != 0 + assert isinstance(result.exception, AuthRequired) + + +# ----- error slug mapping ----- + + +def test_error_slug_referral_code_not_found() -> None: + err = error_from_body( + 404, + {"error": "referral_code_not_found", "message": "Referral code not found."}, + ) + assert isinstance(err, NotFound) + assert err.slug == "referral_code_not_found" + assert "not found" in err.message.lower() + + +def test_error_slug_self_referral() -> None: + err = error_from_body( + 409, + {"error": "self_referral", "message": "You cannot redeem your own referral code."}, + ) + assert isinstance(err, Conflict) + assert err.slug == "self_referral" + assert "own" in err.message.lower() + + +def test_error_slug_already_referred() -> None: + err = error_from_body( + 409, + {"error": "already_referred", "message": "You have already used a referral code."}, + ) + assert isinstance(err, Conflict) + assert err.slug == "already_referred" + assert "already" in err.message.lower() + + +def test_error_slug_referral_not_eligible() -> None: + err = error_from_body( + 409, + {"error": "referral_not_eligible", "message": "This referral code is no longer eligible."}, + ) + assert isinstance(err, Conflict) + assert err.slug == "referral_not_eligible" + assert "eligible" in err.message.lower() + + +@respx.mock +def test_referral_redeem_surfaces_self_referral_error( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={"error": "self_referral", "message": "You cannot redeem your own referral code."}, + ) + ) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "MYCODE"]) + assert result.exit_code != 0 + assert isinstance(result.exception, Conflict) + assert result.exception.slug == "self_referral" + + +@respx.mock +def test_referral_redeem_surfaces_already_referred_error( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={"error": "already_referred", "message": "You have already used a referral code."}, + ) + ) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "SOMEREF"]) + assert result.exit_code != 0 + assert isinstance(result.exception, Conflict) + assert result.exception.slug == "already_referred" + + +@respx.mock +def test_referral_redeem_surfaces_code_not_found_error( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.get(f"{SERVER}/v1/referrals/me").mock( + return_value=httpx.Response( + 404, + json={"error": "referral_code_not_found", "message": "Referral code not found."}, + ) + ) + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 404, + json={"error": "referral_code_not_found", "message": "Referral code not found."}, + ) + ) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "BADCODE"]) + assert result.exit_code != 0 + assert isinstance(result.exception, NotFound) + assert result.exception.slug == "referral_code_not_found" + + +@respx.mock +def test_referral_redeem_surfaces_not_eligible_error( + tmp_config_paths: ConfigPaths, monkeypatch +) -> None: + _env(monkeypatch, tmp_config_paths, api_key="good_live_EXAMPLE_key") + respx.post(f"{SERVER}/v1/referrals/redeem").mock( + return_value=httpx.Response( + 409, + json={ + "error": "referral_not_eligible", + "message": "This referral code is no longer eligible.", + }, + ) + ) + runner = CliRunner() + result = runner.invoke(app, ["referrals", "redeem", "EXPIREDCODE"]) + assert result.exit_code != 0 + assert isinstance(result.exception, Conflict) + assert result.exception.slug == "referral_not_eligible" + + +# ----- module-level guards ----- + + +def test_referral_commands_no_em_dash() -> None: + """The brand constraint forbids em dashes anywhere in source.""" + from pathlib import Path + + src = ( + Path(__file__).resolve().parent.parent / "src" / "goodeye_cli" / "commands" / "referrals.py" + ) + assert "—" not in src.read_text() + + +def test_referral_command_appears_in_help() -> None: + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "referral" in result.output + + +# Avoid "imported but unused" if pytest is reordered. +_ = pytest