Two coupled changes:
1. Lite tier
- New Tier.LITE in src/license/schema.py.
- FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
Format Standardizer}. The three universally-useful tools that
cover the most common bookkeeping / RevOps / Klaviyo prep
workflows. Other six tools require Core.
- i18n: license.tier_lite, license.feature_locked_title,
license.feature_locked_body, license.upgrade_link,
license.status_locked (en + es).
- Per-tool feature gate at every GUI tool page
(require_feature_or_render_upgrade) and every tool CLI
(guard(feature=...)). A locked tool renders an upgrade
prompt + Manage-license button (GUI) or exits with code 2
(CLI).
- Home grid: tool cards the user's tier doesn't unlock get a
red 🔒 Locked badge in place of green Ready.
2. Trial removed
- Activation form's "Start 1-year trial" button removed.
- license_cli's `trial` subcommand removed.
- activation.trial_button / activation.trial_help i18n keys
dropped (pack parity test stays green).
- Tier.TRIAL stays in the enum (back-compat with any field-
tested trial licenses); LicenseManager._mint stays internal
for tests and the seller's key generator.
- Decision logged in DECISIONS §9b: a 1-year all-features
trial undercuts paid Lite; paid-only keeps tier economics
clean.
Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
9.6 KiB
Python
256 lines
9.6 KiB
Python
"""Tests for the license CLI + the per-CLI guard.
|
|
|
|
Two layers:
|
|
|
|
1. ``src/license_cli.py`` commands — ``activate``, ``renew``,
|
|
``status``, ``trial``, ``deactivate``. Invoked via Typer's testing
|
|
helper so we get a clean ``CliRunner.invoke`` interface without
|
|
spawning subprocesses for every test.
|
|
|
|
2. ``src/cli_license_guard.py`` — verify that every existing tool CLI
|
|
refuses to run when no license is present, and that ``--help``
|
|
always works regardless of license state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from src.license_cli import app as license_app
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# license_cli commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLicenseCliStatus:
|
|
def test_status_without_activation_exits_nonzero(self, unactivated_license_manager):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, ["status"])
|
|
assert result.exit_code == 1
|
|
assert "not activated" in result.stdout.lower()
|
|
|
|
def test_status_with_active_license(self, activated_license_manager):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, ["status"])
|
|
assert result.exit_code == 0
|
|
assert "active" in result.stdout.lower()
|
|
assert "Test User" in result.stdout
|
|
|
|
def test_status_json_emits_valid_json(self, activated_license_manager):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, ["status", "--json"])
|
|
assert result.exit_code == 0
|
|
data = json.loads(result.stdout)
|
|
assert data["activated"] is True
|
|
assert data["name"] == "Test User"
|
|
assert data["tier"] == "core"
|
|
assert isinstance(data["features"], list)
|
|
assert data["days_remaining"] >= 0
|
|
|
|
|
|
class TestNoTrialSubcommand:
|
|
"""The ``trial`` subcommand was removed in v1.6 (no free trial —
|
|
paid licenses only). Pin its absence so a future re-add has to be
|
|
intentional."""
|
|
|
|
def test_trial_subcommand_not_registered(self):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, ["trial", "--help"])
|
|
assert result.exit_code != 0
|
|
# Typer surfaces unknown commands with "No such command" or
|
|
# similar — exact wording varies by version, so we just confirm
|
|
# it's a usage error, not a successful execution.
|
|
|
|
|
|
class TestLicenseCliActivate:
|
|
def _make_blob(self, name="Buyer", email="buyer@example.com", tier="core"):
|
|
"""Mint a blob via the same machinery scripts/generate_license.py uses."""
|
|
from src.license import LicenseManager, Tier
|
|
from src.license.crypto import encode_blob
|
|
# Use a throwaway manager (separate path) so we don't trample
|
|
# the one the test is exercising.
|
|
from tempfile import mkstemp
|
|
_, p = mkstemp(suffix=".json")
|
|
mgr = LicenseManager()
|
|
mgr._path = Path(p)
|
|
lic = mgr._mint(name=name, email=email, tier=Tier(tier))
|
|
Path(p).unlink(missing_ok=True)
|
|
return encode_blob(lic.to_dict())
|
|
|
|
def test_activate_round_trip(self, unactivated_license_manager):
|
|
blob = self._make_blob()
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, [
|
|
"activate", blob,
|
|
"--name", "Buyer", "--email", "buyer@example.com",
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "Activated" in result.stdout
|
|
# State is now active.
|
|
from src.license import LicenseManager
|
|
assert LicenseManager().is_valid()
|
|
|
|
def test_activate_rejects_wrong_name(self, unactivated_license_manager):
|
|
blob = self._make_blob()
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, [
|
|
"activate", blob,
|
|
"--name", "Wrong Person", "--email", "buyer@example.com",
|
|
])
|
|
assert result.exit_code == 2
|
|
assert "do not match" in result.output.lower()
|
|
|
|
|
|
class TestLicenseCliRenew:
|
|
def test_renew_extends_expiry(self, activated_license_manager):
|
|
# Mint a longer-duration blob for the same buyer.
|
|
from src.license import LicenseManager, Tier
|
|
from src.license.crypto import encode_blob
|
|
from tempfile import mkstemp
|
|
_, p = mkstemp(suffix=".json")
|
|
other = LicenseManager()
|
|
other._path = Path(p)
|
|
lic = other._mint(
|
|
name="Test User", email="test@example.com",
|
|
tier=Tier.CORE, years=2,
|
|
)
|
|
Path(p).unlink(missing_ok=True)
|
|
blob = encode_blob(lic.to_dict())
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, ["renew", blob])
|
|
assert result.exit_code == 0
|
|
assert "Renewed" in result.stdout
|
|
|
|
|
|
class TestLicenseCliDeactivate:
|
|
def test_deactivate_with_yes(self, activated_license_manager):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, ["deactivate", "--yes"])
|
|
assert result.exit_code == 0
|
|
assert "Deactivated" in result.stdout
|
|
from src.license import LicenseManager
|
|
assert not LicenseManager().is_activated()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Guard tests — every tool CLI refuses to run without a license
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCliLicenseGuard:
|
|
"""Run each tool CLI as a subprocess so we exercise the real
|
|
``main()`` path, including the guard call. We bypass the suite's
|
|
DEV_MODE bypass by clearing the env var in the subprocess."""
|
|
|
|
@pytest.fixture
|
|
def clean_env(self, tmp_path):
|
|
"""Subprocess env: no DEV_MODE, license path in tmp_path."""
|
|
env = dict(os.environ)
|
|
env.pop("DATATOOLS_DEV_MODE", None)
|
|
env["DATATOOLS_LICENSE_PATH"] = str(tmp_path / "license.json")
|
|
return env
|
|
|
|
def _run(self, env, *args, expect_success=False):
|
|
proc = subprocess.run(
|
|
[sys.executable, "-m", *args],
|
|
cwd=PROJECT_ROOT,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if expect_success:
|
|
assert proc.returncode == 0, (
|
|
f"Expected success, got {proc.returncode}\n"
|
|
f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
|
|
)
|
|
return proc
|
|
|
|
@pytest.mark.parametrize("module", [
|
|
"src.cli",
|
|
"src.cli_text_clean",
|
|
"src.cli_format",
|
|
"src.cli_missing",
|
|
"src.cli_column_map",
|
|
"src.cli_pipeline",
|
|
"src.cli_analyze",
|
|
])
|
|
def test_cli_blocked_without_license(self, clean_env, module):
|
|
# Run with a dummy filename so we'd otherwise be running the
|
|
# tool; the guard should fire BEFORE typer parses argv.
|
|
proc = self._run(clean_env, module, "/nonexistent.csv")
|
|
assert proc.returncode == 2
|
|
assert "not activated" in proc.stderr.lower() or "license" in proc.stderr.lower()
|
|
|
|
@pytest.mark.parametrize("module", [
|
|
"src.cli",
|
|
"src.cli_text_clean",
|
|
"src.cli_format",
|
|
"src.cli_missing",
|
|
"src.cli_column_map",
|
|
"src.cli_pipeline",
|
|
"src.cli_analyze",
|
|
])
|
|
def test_help_always_works(self, clean_env, module):
|
|
# ``--help`` must bypass the guard so users can see usage
|
|
# before they activate.
|
|
proc = self._run(clean_env, module, "--help", expect_success=True)
|
|
assert "usage" in (proc.stdout + proc.stderr).lower()
|
|
|
|
def test_dev_mode_bypasses_guard(self, clean_env, tmp_path):
|
|
# Set DEV_MODE; the guard should allow the CLI to run (and
|
|
# then fail on the missing input file with a non-license
|
|
# error — which is what we're asserting via stderr).
|
|
env = dict(clean_env)
|
|
env["DATATOOLS_DEV_MODE"] = "1"
|
|
proc = self._run(env, "src.cli_analyze", "/nonexistent.csv")
|
|
# We expect typer / our code to fail on the missing path,
|
|
# NOT on the license. Look for evidence the license check
|
|
# was bypassed.
|
|
assert "not activated" not in proc.stderr.lower()
|
|
# Either pandas / our io.py raises a FileNotFoundError-ish.
|
|
combined = (proc.stdout + proc.stderr).lower()
|
|
assert "no such file" in combined or "not found" in combined or proc.returncode != 0
|
|
|
|
|
|
class TestGuardBypassesHelp:
|
|
"""Sanity check: ``--help`` and friends must skip the guard so the
|
|
Typer help screen renders even when no license is on disk."""
|
|
|
|
def test_runs_under_help_flag_without_license(self, tmp_path, monkeypatch):
|
|
# In-process check via the guard helper.
|
|
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
|
|
monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"))
|
|
from src.license.manager import reset_singleton_for_tests
|
|
reset_singleton_for_tests()
|
|
|
|
monkeypatch.setattr("sys.argv", ["progname", "--help"])
|
|
from src.cli_license_guard import guard
|
|
# No exception expected: --help bypasses.
|
|
guard()
|
|
|
|
def test_blocks_under_real_command_without_license(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
|
|
monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"))
|
|
from src.license.manager import reset_singleton_for_tests
|
|
reset_singleton_for_tests()
|
|
|
|
monkeypatch.setattr("sys.argv", ["progname", "input.csv", "--apply"])
|
|
from src.cli_license_guard import guard
|
|
with pytest.raises(SystemExit) as ei:
|
|
guard()
|
|
assert ei.value.code == 2
|