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>
This commit is contained in:
@@ -152,21 +152,18 @@ class TestActivationFormSubmission:
|
||||
text = collected_text(home_app)
|
||||
assert "Data Cleaning Mastery" in text
|
||||
|
||||
def test_trial_button_self_issues_license(
|
||||
self, no_license_env, home_app,
|
||||
):
|
||||
def test_trial_button_absent_paid_only(self, no_license_env, home_app):
|
||||
"""v1.6 dropped the user-facing trial flow — paid licenses only.
|
||||
Verify the trial button is not on the activation form."""
|
||||
home_app.run()
|
||||
home_app.text_input(key="gate_name").set_value("Trial").run()
|
||||
home_app.text_input(key="gate_email").set_value("trial@example.com").run()
|
||||
# Click the trial button on the same form.
|
||||
trial_btn = next(
|
||||
b for b in home_app.button
|
||||
if "trial" in b.label.lower() or "prueba" in b.label.lower()
|
||||
)
|
||||
trial_btn.click().run()
|
||||
text = collected_text(home_app)
|
||||
# Successful activation → home page renders fully.
|
||||
assert "Data Cleaning Mastery" in text
|
||||
labels = [b.label for b in home_app.button]
|
||||
for lbl in labels:
|
||||
assert "trial" not in lbl.lower(), (
|
||||
f"trial button leaked into activation form: {lbl!r}"
|
||||
)
|
||||
assert "prueba" not in lbl.lower(), (
|
||||
f"Spanish trial button leaked into form: {lbl!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestActivationPageDirect:
|
||||
|
||||
135
tests/gui/test_lite_tier.py
Normal file
135
tests/gui/test_lite_tier.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""GUI tests for the Lite tier.
|
||||
|
||||
A Lite license unlocks Deduplicator, Text Cleaner, Format
|
||||
Standardizer. Opening any other tool page (Missing Values, Column
|
||||
Mapper, Pipeline Runner, etc.) must render an upgrade prompt and
|
||||
short-circuit the page body.
|
||||
|
||||
The home grid shows a 🔒 Locked badge on the cards for tools the
|
||||
user's tier doesn't unlock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import collected_text, stash_upload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lite_license(monkeypatch, tmp_path):
|
||||
"""Activate a Lite license; return nothing — the env vars route
|
||||
every page on this test through this license."""
|
||||
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()
|
||||
from src.license import LicenseManager, Tier
|
||||
LicenseManager()._mint(
|
||||
name="Lite User", email="lite@example.com", tier=Tier.LITE,
|
||||
)
|
||||
yield
|
||||
reset_singleton_for_tests()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unlocked tools render normally
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteUnlockedPages:
|
||||
@pytest.mark.parametrize("slug,signal", [
|
||||
("1_Deduplicator", "Deduplicator"),
|
||||
("2_Text_Cleaner", "Text Cleaner"),
|
||||
("3_Format_Standardizer", "Format Standardizer"),
|
||||
])
|
||||
def test_unlocked_pages_render_body(
|
||||
self, lite_license, app_factory, slug, signal, small_csv_bytes,
|
||||
):
|
||||
app = app_factory(slug)
|
||||
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
||||
app.run()
|
||||
text = collected_text(app)
|
||||
assert signal in text
|
||||
# No upgrade prompt.
|
||||
assert "isn't on your" not in text and "no está incluida" not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locked tools render upgrade prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteLockedPages:
|
||||
@pytest.mark.parametrize("slug", [
|
||||
"4_Missing_Values",
|
||||
"5_Column_Mapper",
|
||||
"9_Pipeline_Runner",
|
||||
"6_Outlier_Detector",
|
||||
"7_Multi_File_Merger",
|
||||
"8_Validator_Reporter",
|
||||
])
|
||||
def test_locked_page_shows_upgrade_prompt(
|
||||
self, lite_license, app_factory, slug, small_csv_bytes,
|
||||
):
|
||||
app = app_factory(slug)
|
||||
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
||||
app.run()
|
||||
text = collected_text(app)
|
||||
# Upgrade prompt title carries the localized lock + tier label.
|
||||
assert "isn't on your" in text or "no está incluida" in text, (
|
||||
f"locked page {slug!r} missing upgrade prompt; got:\n"
|
||||
f"{text[:500]}"
|
||||
)
|
||||
|
||||
def test_upgrade_button_present(
|
||||
self, lite_license, app_factory, small_csv_bytes,
|
||||
):
|
||||
app = app_factory("4_Missing_Values")
|
||||
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
||||
app.run()
|
||||
labels = [b.label for b in app.button]
|
||||
assert any("Manage license" in lbl for lbl in labels), (
|
||||
f"upgrade prompt missing Manage-license button; got: {labels}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home grid shows lock badges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteHomeGridBadges:
|
||||
def test_locked_tool_card_shows_lock_badge(
|
||||
self, lite_license, home_app,
|
||||
):
|
||||
home_app.run()
|
||||
text = collected_text(home_app)
|
||||
# Missing Value Handler is locked under Lite — its card should
|
||||
# have a 🔒 Locked badge.
|
||||
# We assert the lock glyph appears alongside the locked tool's
|
||||
# display name. Streamlit renders the markdown verbatim so the
|
||||
# ``🔒 Locked`` text appears in the page markdown stream.
|
||||
assert "🔒" in text or "Locked" in text, (
|
||||
"home grid missing lock badge for Lite-locked tool"
|
||||
)
|
||||
|
||||
def test_unlocked_tool_card_no_lock(self, lite_license, home_app):
|
||||
home_app.run()
|
||||
# Dedup is unlocked under Lite. Its card markdown should NOT
|
||||
# contain a lock glyph adjacent to its name. We can't easily
|
||||
# scope by card without parsing the markdown stream, but we
|
||||
# can confirm both ``Ready`` (unlocked) and ``Locked``
|
||||
# (locked) badges coexist on the page.
|
||||
text = collected_text(home_app)
|
||||
assert "Ready" in text or "Listo" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidebar status shows Lite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteSidebarStatus:
|
||||
def test_sidebar_caption_mentions_lite(self, lite_license, home_app):
|
||||
home_app.run()
|
||||
captions = " ".join(c.value for c in home_app.sidebar.caption)
|
||||
assert "Lite" in captions
|
||||
@@ -59,31 +59,18 @@ class TestLicenseCliStatus:
|
||||
assert data["days_remaining"] >= 0
|
||||
|
||||
|
||||
class TestLicenseCliTrial:
|
||||
def test_trial_issues_one_year_license(self, unactivated_license_manager):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(license_app, [
|
||||
"trial", "--name", "Trial User", "--email", "trial@example.com",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert "Trial issued" in result.stdout
|
||||
# And the manager now sees it as active.
|
||||
from src.license import LicenseManager
|
||||
mgr = LicenseManager()
|
||||
assert mgr.is_valid()
|
||||
lic = mgr.load()
|
||||
assert lic.tier.value == "trial"
|
||||
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_rejects_bad_email(self, unactivated_license_manager):
|
||||
def test_trial_subcommand_not_registered(self):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(license_app, [
|
||||
"trial", "--name", "T", "--email", "not-an-email",
|
||||
])
|
||||
assert result.exit_code == 2
|
||||
# ``typer.echo(..., err=True)`` lands in ``result.output`` when
|
||||
# ``mix_stderr`` is the default True; ``result.stdout`` only has
|
||||
# the bare stdout.
|
||||
assert "valid email" in result.output.lower()
|
||||
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:
|
||||
|
||||
150
tests/test_lite_tier.py
Normal file
150
tests/test_lite_tier.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Tier-specific tests: Lite tier feature set + gating.
|
||||
|
||||
Lite unlocks exactly three tools — Deduplicator, Text Cleaner,
|
||||
Format Standardizer — and locks the other six. We test:
|
||||
|
||||
- The features map for Lite returns the right three flags (and only
|
||||
those three).
|
||||
- A Lite-tier license passes ``require_feature`` for any of the three
|
||||
unlocked flags and raises ``UnsupportedFeatureError`` for any
|
||||
locked flag.
|
||||
- The CLI guard exits with code 2 when a Lite user runs a non-Lite
|
||||
tool CLI.
|
||||
|
||||
GUI behaviour is covered in ``tests/gui/test_lite_tier.py`` so the
|
||||
GUI/non-GUI split stays clean.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.license import (
|
||||
FeatureFlag,
|
||||
LicenseManager,
|
||||
Tier,
|
||||
UnsupportedFeatureError,
|
||||
)
|
||||
from src.license.features import FEATURES_BY_TIER, all_features_for_tier
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Features map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteFeatureMap:
|
||||
def test_lite_unlocks_exactly_three_tools(self):
|
||||
flags = FEATURES_BY_TIER[Tier.LITE]
|
||||
assert flags == frozenset({
|
||||
FeatureFlag.DEDUPLICATOR,
|
||||
FeatureFlag.TEXT_CLEANER,
|
||||
FeatureFlag.FORMAT_STANDARDIZER,
|
||||
})
|
||||
|
||||
def test_lite_locks_other_six_tools(self):
|
||||
flags = FEATURES_BY_TIER[Tier.LITE]
|
||||
locked = {
|
||||
FeatureFlag.MISSING_HANDLER,
|
||||
FeatureFlag.COLUMN_MAPPER,
|
||||
FeatureFlag.OUTLIER_DETECTOR,
|
||||
FeatureFlag.MULTI_FILE_MERGER,
|
||||
FeatureFlag.VALIDATOR_REPORTER,
|
||||
FeatureFlag.PIPELINE_RUNNER,
|
||||
}
|
||||
for f in locked:
|
||||
assert f not in flags
|
||||
|
||||
def test_all_features_for_tier_lite_returns_sorted_tuple(self):
|
||||
tup = all_features_for_tier(Tier.LITE)
|
||||
assert tup == tuple(sorted(tup))
|
||||
assert len(tup) == 3
|
||||
|
||||
def test_core_still_unlocks_every_tool(self):
|
||||
"""Sanity: adding Lite didn't accidentally narrow Core."""
|
||||
assert FEATURES_BY_TIER[Tier.CORE] == frozenset(FeatureFlag)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteLicenseGating:
|
||||
@pytest.fixture
|
||||
def lite_manager(self, isolated_license_path):
|
||||
"""Pre-activate a Lite license and return the manager."""
|
||||
mgr = LicenseManager()
|
||||
mgr._mint(name="Lite User", email="lite@example.com", tier=Tier.LITE)
|
||||
return mgr
|
||||
|
||||
@pytest.mark.parametrize("feature", [
|
||||
FeatureFlag.DEDUPLICATOR,
|
||||
FeatureFlag.TEXT_CLEANER,
|
||||
FeatureFlag.FORMAT_STANDARDIZER,
|
||||
])
|
||||
def test_lite_passes_for_included_features(self, lite_manager, feature):
|
||||
lite_manager.require_feature(feature) # no raise
|
||||
|
||||
@pytest.mark.parametrize("feature", [
|
||||
FeatureFlag.MISSING_HANDLER,
|
||||
FeatureFlag.COLUMN_MAPPER,
|
||||
FeatureFlag.OUTLIER_DETECTOR,
|
||||
FeatureFlag.MULTI_FILE_MERGER,
|
||||
FeatureFlag.VALIDATOR_REPORTER,
|
||||
FeatureFlag.PIPELINE_RUNNER,
|
||||
])
|
||||
def test_lite_raises_for_locked_features(self, lite_manager, feature):
|
||||
with pytest.raises(UnsupportedFeatureError):
|
||||
lite_manager.require_feature(feature)
|
||||
|
||||
def test_lite_state_carries_lite_tier(self, lite_manager):
|
||||
state = lite_manager.current_state()
|
||||
assert state.tier == "lite"
|
||||
assert state.valid is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteCliGuard:
|
||||
"""In-process tests of the guard. Subprocess equivalents would be
|
||||
slower; the unit-level coverage is sufficient because the guard
|
||||
is pure Python and the entrypoint plumbing is already tested in
|
||||
``test_license_cli.py``."""
|
||||
|
||||
def _activate_lite(self, monkeypatch, tmp_path):
|
||||
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()
|
||||
from src.license import LicenseManager
|
||||
LicenseManager()._mint(
|
||||
name="A", email="a@b.com", tier=Tier.LITE,
|
||||
)
|
||||
|
||||
def test_guard_passes_for_unlocked_feature(self, monkeypatch, tmp_path):
|
||||
self._activate_lite(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("sys.argv", ["prog", "input.csv"])
|
||||
from src.cli_license_guard import guard
|
||||
# Lite unlocks text cleaner — guard must not raise.
|
||||
guard(feature="02_text_cleaner")
|
||||
|
||||
def test_guard_rejects_locked_feature(self, monkeypatch, tmp_path, capsys):
|
||||
self._activate_lite(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("sys.argv", ["prog", "input.csv"])
|
||||
from src.cli_license_guard import guard
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
guard(feature="04_missing_handler")
|
||||
assert ei.value.code == 2
|
||||
captured = capsys.readouterr()
|
||||
assert "04_missing_handler" in captured.err
|
||||
assert "upgrade" in captured.err.lower() or "tier" in captured.err.lower()
|
||||
|
||||
def test_guard_help_bypasses_feature_check(self, monkeypatch, tmp_path):
|
||||
self._activate_lite(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("sys.argv", ["prog", "--help"])
|
||||
from src.cli_license_guard import guard
|
||||
# --help still bypasses the guard entirely.
|
||||
guard(feature="04_missing_handler")
|
||||
Reference in New Issue
Block a user