Files
datatools-dev/tests/test_license_cli.py
Michael d32b58e61a feat(license): add Lite SKU; remove user-facing free trial
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>
2026-05-13 17:19:30 +00:00

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