From c97200b576da9540763a28291e33d5e575a8b464 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 15 Jun 2026 01:00:09 -0700 Subject: [PATCH 1/5] feat: add referral status and redeem commands --- src/goodeye_cli/app.py | 2 + src/goodeye_cli/client.py | 10 + src/goodeye_cli/commands/referrals.py | 96 +++++++++ src/goodeye_cli/errors.py | 4 + src/goodeye_cli/wire.py | 23 +++ tests/test_commands_referrals.py | 282 ++++++++++++++++++++++++++ 6 files changed, 417 insertions(+) create mode 100644 src/goodeye_cli/commands/referrals.py create mode 100644 tests/test_commands_referrals.py diff --git a/src/goodeye_cli/app.py b/src/goodeye_cli/app.py index ff54c20..6a14e79 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="referral", 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/referrals.py b/src/goodeye_cli/commands/referrals.py new file mode 100644 index 0000000..72a1e47 --- /dev/null +++ b/src/goodeye_cli/commands/referrals.py @@ -0,0 +1,96 @@ +"""`goodeye referral ...` 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 + +app = typer.Typer(help="View and redeem referral codes.", no_args_is_help=True) + + +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 qualified for credits, 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, + "qualified_count": result.qualified_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"Qualified: {result.qualified_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. Credits are granted once your account + meets the qualification criteria. The referrer also receives credits when + you qualify. + """ + 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(f"[green]Referral redeemed[/green] (status: {result.status})") + console.print(f"Credits granted: ${result.credits_granted_usd}") + console.print(f"Referred by: @{result.referrer_handle}") + console.print(f"Expires at: {result.expires_at.isoformat()}") + + +__all__ = ["app", "redeem", "status"] 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..2e6bc36 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 + qualified_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_referrals.py b/tests/test_commands_referrals.py new file mode 100644 index 0000000..b393b37 --- /dev/null +++ b/tests/test_commands_referrals.py @@ -0,0 +1,282 @@ +"""Tests for the `goodeye referral ...` 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, + "qualified_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, ["referral", "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 "Qualified: 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, ["referral", "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["qualified_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, ["referral", "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, ["referral", "redeem", "GOODEYE-ABC123"]) + assert result.exit_code == 0, result.output + assert "redeemed" in result.output.lower() + assert "pending" in result.output + assert "$5.00" in result.output + assert "@alice" 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, ["referral", "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, ["referral", "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, ["referral", "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, ["referral", "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, ["referral", "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, ["referral", "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 From 349ea0dce16b3f9fec92faa28b63e90b6fd77b52 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 15 Jun 2026 01:10:28 -0700 Subject: [PATCH 2/5] feat: redeem a referral code during register and login Add --referral-code to register-verify, login (device-code), and login-verify. After credentials are saved, the new _maybe_redeem_referral helper calls the redeem endpoint with the supplied code. Failure is non-fatal: the user stays signed in and a yellow warning is printed. Initiation hints in register and login --email mention the flag for the verify step. Co-Authored-By: Claude Sonnet 4.6 --- src/goodeye_cli/commands/login.py | 16 +- src/goodeye_cli/commands/referrals.py | 28 +- src/goodeye_cli/commands/register.py | 10 +- .../test_commands_login_register_referral.py | 473 ++++++++++++++++++ 4 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 tests/test_commands_login_register_referral.py 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 index 72a1e47..b7c38a6 100644 --- a/src/goodeye_cli/commands/referrals.py +++ b/src/goodeye_cli/commands/referrals.py @@ -12,11 +12,35 @@ from goodeye_cli.client import GoodeyeClient from goodeye_cli.config import get_api_key, get_server -from goodeye_cli.errors import AuthRequired +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: @@ -93,4 +117,4 @@ def redeem( console.print(f"Expires at: {result.expires_at.isoformat()}") -__all__ = ["app", "redeem", "status"] +__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/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() From af5ebc98a26e8eaf00e40938d110485b7add7b4c Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 15 Jun 2026 09:45:11 -0700 Subject: [PATCH 3/5] Fix referral redeem help text and blank-handle output The redeem help text said bonus credits arrive after qualification, but the invitee bonus lands immediately on a valid redeem; only the referrer reward is paid later, once the redeemer's account is activated. Correct the wording so the help matches actual behavior. Also guard the human output against a blank referrer handle so the CLI no longer prints a bare "@" when the server returns an empty referrer_handle. Co-Authored-By: Claude Opus 4.8 --- src/goodeye_cli/commands/referrals.py | 9 +++++---- tests/test_commands_referrals.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/goodeye_cli/commands/referrals.py b/src/goodeye_cli/commands/referrals.py index b7c38a6..6904e39 100644 --- a/src/goodeye_cli/commands/referrals.py +++ b/src/goodeye_cli/commands/referrals.py @@ -93,9 +93,9 @@ def redeem( ) -> None: """Redeem a referral code from another Goodeye user. - Applies the referral to your account. Credits are granted once your account - meets the qualification criteria. The referrer also receives credits when - you qualify. + 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: @@ -113,7 +113,8 @@ def redeem( console.print(f"[green]Referral redeemed[/green] (status: {result.status})") console.print(f"Credits granted: ${result.credits_granted_usd}") - console.print(f"Referred by: @{result.referrer_handle}") + if result.referrer_handle: + console.print(f"Referred by: @{result.referrer_handle}") console.print(f"Expires at: {result.expires_at.isoformat()}") diff --git a/tests/test_commands_referrals.py b/tests/test_commands_referrals.py index b393b37..e808466 100644 --- a/tests/test_commands_referrals.py +++ b/tests/test_commands_referrals.py @@ -110,6 +110,23 @@ def test_referral_redeem_human(tmp_config_paths: ConfigPaths, monkeypatch) -> No assert "@alice" 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, ["referral", "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") From 93076c28e7f728b77003ffecc07ca6c65a22a9f4 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 15 Jun 2026 10:17:58 -0700 Subject: [PATCH 4/5] Rename referral command group to referrals for plural consistency Aligns the subcommand group with every other group (teams, templates, verifiers, invitations, workflows) which all use the plural form. Only the user-typed command token changes; the --referral-code flag, prose, wire models, and client methods stay as-is. Co-Authored-By: Claude Opus 4.8 --- src/goodeye_cli/app.py | 2 +- src/goodeye_cli/commands/referrals.py | 2 +- tests/test_commands_referrals.py | 24 ++++++++++++------------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/goodeye_cli/app.py b/src/goodeye_cli/app.py index 6a14e79..1b925b8 100644 --- a/src/goodeye_cli/app.py +++ b/src/goodeye_cli/app.py @@ -66,7 +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="referral", help="View and redeem referral codes.") +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/commands/referrals.py b/src/goodeye_cli/commands/referrals.py index 6904e39..1c295a2 100644 --- a/src/goodeye_cli/commands/referrals.py +++ b/src/goodeye_cli/commands/referrals.py @@ -1,4 +1,4 @@ -"""`goodeye referral ...` subcommand group. +"""`goodeye referrals ...` subcommand group. Covers viewing your referral code and redeeming a referral from another user. """ diff --git a/tests/test_commands_referrals.py b/tests/test_commands_referrals.py index e808466..f0e838b 100644 --- a/tests/test_commands_referrals.py +++ b/tests/test_commands_referrals.py @@ -1,4 +1,4 @@ -"""Tests for the `goodeye referral ...` subcommand group. +"""Tests for the `goodeye referrals ...` subcommand group. Covers happy-path status and redeem, --json output, all four error slugs, and missing credentials. @@ -58,7 +58,7 @@ def test_referral_status_human(tmp_config_paths: ConfigPaths, monkeypatch) -> No _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, ["referral", "status"]) + 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 @@ -73,7 +73,7 @@ def test_referral_status_json(tmp_config_paths: ConfigPaths, monkeypatch) -> Non _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, ["referral", "status", "--json"]) + 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" @@ -87,7 +87,7 @@ def test_referral_status_json(tmp_config_paths: ConfigPaths, monkeypatch) -> Non 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, ["referral", "status"]) + result = runner.invoke(app, ["referrals", "status"]) assert result.exit_code != 0 assert isinstance(result.exception, AuthRequired) @@ -102,7 +102,7 @@ def test_referral_redeem_human(tmp_config_paths: ConfigPaths, monkeypatch) -> No return_value=httpx.Response(200, json=_REDEEM_BODY) ) runner = CliRunner() - result = runner.invoke(app, ["referral", "redeem", "GOODEYE-ABC123"]) + result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123"]) assert result.exit_code == 0, result.output assert "redeemed" in result.output.lower() assert "pending" in result.output @@ -119,7 +119,7 @@ def test_referral_redeem_human_blank_referrer_handle( 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, ["referral", "redeem", "GOODEYE-ABC123"]) + 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 @@ -134,7 +134,7 @@ def test_referral_redeem_json(tmp_config_paths: ConfigPaths, monkeypatch) -> Non return_value=httpx.Response(200, json=_REDEEM_BODY) ) runner = CliRunner() - result = runner.invoke(app, ["referral", "redeem", "GOODEYE-ABC123", "--json"]) + 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" @@ -146,7 +146,7 @@ def test_referral_redeem_json(tmp_config_paths: ConfigPaths, monkeypatch) -> Non 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, ["referral", "redeem", "GOODEYE-ABC123"]) + result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123"]) assert result.exit_code != 0 assert isinstance(result.exception, AuthRequired) @@ -206,7 +206,7 @@ def test_referral_redeem_surfaces_self_referral_error( ) ) runner = CliRunner() - result = runner.invoke(app, ["referral", "redeem", "MYCODE"]) + result = runner.invoke(app, ["referrals", "redeem", "MYCODE"]) assert result.exit_code != 0 assert isinstance(result.exception, Conflict) assert result.exception.slug == "self_referral" @@ -224,7 +224,7 @@ def test_referral_redeem_surfaces_already_referred_error( ) ) runner = CliRunner() - result = runner.invoke(app, ["referral", "redeem", "SOMEREF"]) + result = runner.invoke(app, ["referrals", "redeem", "SOMEREF"]) assert result.exit_code != 0 assert isinstance(result.exception, Conflict) assert result.exception.slug == "already_referred" @@ -248,7 +248,7 @@ def test_referral_redeem_surfaces_code_not_found_error( ) ) runner = CliRunner() - result = runner.invoke(app, ["referral", "redeem", "BADCODE"]) + 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" @@ -269,7 +269,7 @@ def test_referral_redeem_surfaces_not_eligible_error( ) ) runner = CliRunner() - result = runner.invoke(app, ["referral", "redeem", "EXPIREDCODE"]) + result = runner.invoke(app, ["referrals", "redeem", "EXPIREDCODE"]) assert result.exit_code != 0 assert isinstance(result.exception, Conflict) assert result.exception.slug == "referral_not_eligible" From b3cfa8ce524400677343e06821df61612273dd50 Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Mon, 15 Jun 2026 10:37:40 -0700 Subject: [PATCH 5/5] Use Activated wording in referral status and simplify redeem output Rename the user-facing referral status field from "qualified" to "activated" so the CLI matches the server and the "activate your account" language. The status response field is now activated_count across the wire model, the JSON payload, and the human-readable label. Drop the raw "(status: ...)" suffix from the redeem confirmation line; the status value stays in the --json payload for programmatic consumers. Co-Authored-By: Claude Opus 4.8 --- src/goodeye_cli/commands/referrals.py | 8 ++++---- src/goodeye_cli/wire.py | 2 +- tests/test_commands_referrals.py | 9 +++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/goodeye_cli/commands/referrals.py b/src/goodeye_cli/commands/referrals.py index 1c295a2..3661377 100644 --- a/src/goodeye_cli/commands/referrals.py +++ b/src/goodeye_cli/commands/referrals.py @@ -59,7 +59,7 @@ def status( """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 qualified for credits, and how much + people have redeemed it, how many have activated their account, and how much you have earned so far. """ console = Console() @@ -71,7 +71,7 @@ def status( "code": result.code, "instructions": result.instructions, "redeemed_count": result.redeemed_count, - "qualified_count": result.qualified_count, + "activated_count": result.activated_count, "credits_earned_usd": result.credits_earned_usd, "slots_remaining": result.slots_remaining, } @@ -81,7 +81,7 @@ def status( 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"Qualified: {result.qualified_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}") @@ -111,7 +111,7 @@ def redeem( typer.echo(_json.dumps(payload)) return - console.print(f"[green]Referral redeemed[/green] (status: {result.status})") + 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}") diff --git a/src/goodeye_cli/wire.py b/src/goodeye_cli/wire.py index 2e6bc36..b21394f 100644 --- a/src/goodeye_cli/wire.py +++ b/src/goodeye_cli/wire.py @@ -787,7 +787,7 @@ class ReferralStatusResponse(_WireBase): code: str instructions: str redeemed_count: int - qualified_count: int + activated_count: int credits_earned_usd: str slots_remaining: int diff --git a/tests/test_commands_referrals.py b/tests/test_commands_referrals.py index f0e838b..4b82098 100644 --- a/tests/test_commands_referrals.py +++ b/tests/test_commands_referrals.py @@ -28,7 +28,7 @@ "code": "GOODEYE-ABC123", "instructions": "Share this code with a friend to earn credits.", "redeemed_count": 2, - "qualified_count": 1, + "activated_count": 1, "credits_earned_usd": "5.00", "slots_remaining": 8, } @@ -63,7 +63,7 @@ def test_referral_status_human(tmp_config_paths: ConfigPaths, monkeypatch) -> No assert "GOODEYE-ABC123" in result.output assert "Share this code" in result.output assert "Redeemed: 2" in result.output - assert "Qualified: 1" in result.output + assert "Activated: 1" in result.output assert "$5.00" in result.output assert "Slots remaining: 8" in result.output @@ -78,7 +78,7 @@ def test_referral_status_json(tmp_config_paths: ConfigPaths, monkeypatch) -> Non data = json.loads(result.output.strip()) assert data["code"] == "GOODEYE-ABC123" assert data["redeemed_count"] == 2 - assert data["qualified_count"] == 1 + assert data["activated_count"] == 1 assert data["credits_earned_usd"] == "5.00" assert data["slots_remaining"] == 8 assert "instructions" in data @@ -105,9 +105,10 @@ def test_referral_redeem_human(tmp_config_paths: ConfigPaths, monkeypatch) -> No result = runner.invoke(app, ["referrals", "redeem", "GOODEYE-ABC123"]) assert result.exit_code == 0, result.output assert "redeemed" in result.output.lower() - assert "pending" in result.output 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