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>
151 lines
5.5 KiB
Python
151 lines
5.5 KiB
Python
"""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")
|