| """ |
| test_transforms.py - Unit Tests |
| ---------------------------------- |
| Tests for ETL validation logic and MRR calculations. |
| Supports dual-engine execution: tests PySpark if Java is available, |
| or falls back to testing the equivalent Pandas logic if Spark is missing. |
| Run: |
| pytest tests/ -v |
| """ |
|
|
| import pytest |
| import sys |
| import os |
| import pandas as pd |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) |
|
|
| try: |
| from pyspark.sql import SparkSession |
| from pyspark.sql import functions as F |
| from pyspark.sql.types import ( |
| StructType, StructField, StringType, |
| IntegerType, TimestampType, BooleanType |
| ) |
| except ImportError: |
| SparkSession = None |
| F = None |
| StructType = StructField = StringType = IntegerType = TimestampType = BooleanType = None |
|
|
|
|
| @pytest.fixture(scope="session") |
| def spark(): |
| """Single SparkSession shared across all tests. Returns None if Java is missing.""" |
| import shutil |
| if SparkSession is not None and shutil.which("java") is not None: |
| try: |
| return ( |
| SparkSession.builder |
| .appName("Tests") |
| .master("local[2]") |
| .config("spark.sql.shuffle.partitions", "2") |
| .config("spark.ui.enabled", "false") |
| .getOrCreate() |
| ) |
| except Exception: |
| pass |
| return None |
|
|
|
|
| |
|
|
| if StructType is not None: |
| EVENTS_SCHEMA = StructType([ |
| StructField("event_id", StringType(), False), |
| StructField("tenant_id", StringType(), False), |
| StructField("event_type", StringType(), False), |
| StructField("plan", StringType(), True), |
| StructField("amount", IntegerType(), True), |
| StructField("currency", StringType(), True), |
| StructField("event_date", TimestampType(), True), |
| StructField("status", StringType(), True), |
| StructField("invoice_id", StringType(), True), |
| StructField("seats", IntegerType(), True), |
| StructField("trial", BooleanType(), True), |
| ]) |
| else: |
| EVENTS_SCHEMA = None |
|
|
| SAMPLE_EVENTS = [ |
| ("EVT_001", "TNT_001", "invoice_paid", "starter", 299, "INR", "2024-01-15 10:00:00", "success", "INV_001", 2, False), |
| ("EVT_002", "TNT_001", "invoice_paid", "starter", 299, "INR", "2024-02-15 10:00:00", "success", "INV_002", 2, False), |
| ("EVT_003", "TNT_002", "invoice_paid", "growth", 999, "INR", "2024-01-20 10:00:00", "success", "INV_003", 5, False), |
| ("EVT_004", "TNT_001", "subscription_cancelled", "starter", 0, "INR", "2024-03-01 10:00:00", "success", "INV_004", 2, False), |
| ("EVT_005", "TNT_003", "invoice_paid", "business", 2499, "INR", "2024-01-25 10:00:00", "success", "INV_005", 10, False), |
| ("EVT_006", "TNT_999", "invoice_paid", "INVALID", -100, "INR", "2024-01-01 10:00:00", "success", "INV_006", 1, False), |
| ] |
|
|
|
|
| def make_events_df(spark, rows=None): |
| data = rows or SAMPLE_EVENTS |
| |
| from pyspark.sql import functions as F |
| raw = spark.createDataFrame(data, schema=[ |
| "event_id", "tenant_id", "event_type", "plan", "amount", |
| "currency", "event_date_str", "status", "invoice_id", "seats", "trial" |
| ]) |
| return raw.withColumn("event_date", F.to_timestamp("event_date_str", "yyyy-MM-dd HH:mm:ss")).drop("event_date_str") |
|
|
|
|
| def make_events_df_pandas(rows=None): |
| data = rows or SAMPLE_EVENTS |
| df = pd.DataFrame(data, columns=[ |
| "event_id", "tenant_id", "event_type", "plan", "amount", |
| "currency", "event_date_str", "status", "invoice_id", "seats", "trial" |
| ]) |
| df["event_date"] = pd.to_datetime(df["event_date_str"]) |
| return df.drop(columns=["event_date_str"]) |
|
|
|
|
| |
|
|
| class TestValidation: |
|
|
| def test_negative_amount_rejected(self, spark): |
| """Rows with negative amounts must be flagged as invalid.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| invalid = df[df["amount"] < 0] |
| assert len(invalid) == 1, "Expected exactly 1 row with negative amount" |
| else: |
| df = make_events_df(spark) |
| invalid = df.filter(F.col("amount") < 0) |
| assert invalid.count() == 1, "Expected exactly 1 row with negative amount" |
|
|
| def test_valid_events_count(self, spark): |
| """Non-negative, known event_type rows should pass validation.""" |
| valid_types = {"subscription_created", "subscription_upgraded", |
| "subscription_downgraded", "subscription_cancelled", |
| "invoice_paid", "invoice_failed", "trial_started", "trial_converted"} |
| if spark is None: |
| df = make_events_df_pandas() |
| clean = df[(df["amount"] >= 0) & (df["event_type"].isin(valid_types))] |
| assert len(clean) == 5, f"Expected 5 clean rows, got {len(clean)}" |
| else: |
| df = make_events_df(spark) |
| clean = df.filter( |
| F.col("amount") >= 0 |
| ).filter( |
| F.col("event_type").isin(list(valid_types)) |
| ) |
| assert clean.count() == 5, f"Expected 5 clean rows, got {clean.count()}" |
|
|
| def test_no_null_tenant_ids(self, spark): |
| """All sample rows should have non-null tenant_id.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| nulls = df["tenant_id"].isna().sum() |
| assert nulls == 0 |
| else: |
| df = make_events_df(spark) |
| nulls = df.filter(F.col("tenant_id").isNull()).count() |
| assert nulls == 0 |
|
|
|
|
| class TestMRRCalculation: |
|
|
| def test_mrr_sum_correct(self, spark): |
| """MRR for January should be 299 + 999 + 2499 = 3797.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| jan_mrr = df[ |
| (df["event_type"] == "invoice_paid") & |
| (df["status"] == "success") & |
| (df["event_date"].dt.month == 1) & |
| (df["amount"] >= 0) |
| ]["amount"].sum() |
| assert jan_mrr == 3797, f"Expected 3797, got {jan_mrr}" |
| else: |
| df = make_events_df(spark) |
| jan_mrr = ( |
| df |
| .filter(F.col("event_type") == "invoice_paid") |
| .filter(F.col("status") == "success") |
| .filter(F.month("event_date") == 1) |
| .filter(F.col("amount") >= 0) |
| .agg(F.sum("amount").alias("total")) |
| .collect()[0]["total"] |
| ) |
| assert jan_mrr == 3797, f"Expected 3797, got {jan_mrr}" |
|
|
| def test_mrr_per_tenant(self, spark): |
| """TNT_001 should have MRR of 299 in January.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| tnt001_jan = df[ |
| (df["tenant_id"] == "TNT_001") & |
| (df["event_type"] == "invoice_paid") & |
| (df["event_date"].dt.month == 1) |
| ]["amount"].sum() |
| assert tnt001_jan == 299 |
| else: |
| df = make_events_df(spark) |
| tnt001_jan = ( |
| df |
| .filter(F.col("tenant_id") == "TNT_001") |
| .filter(F.col("event_type") == "invoice_paid") |
| .filter(F.month("event_date") == 1) |
| .agg(F.sum("amount")) |
| .collect()[0][0] |
| ) |
| assert tnt001_jan == 299 |
|
|
| def test_cancelled_tenant_zero_revenue(self, spark): |
| """Cancelled event should contribute 0 to revenue.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| cancel_revenue = df[df["event_type"] == "subscription_cancelled"]["amount"].sum() |
| assert cancel_revenue == 0 |
| else: |
| df = make_events_df(spark) |
| cancel_revenue = ( |
| df |
| .filter(F.col("event_type") == "subscription_cancelled") |
| .agg(F.sum("amount")) |
| .collect()[0][0] |
| ) |
| assert cancel_revenue == 0 or cancel_revenue is None |
|
|
|
|
| class TestChurnLogic: |
|
|
| def test_churn_event_count(self, spark): |
| """Sample data has 1 cancellation event.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| churn_count = len(df[df["event_type"] == "subscription_cancelled"]) |
| assert churn_count == 1 |
| else: |
| df = make_events_df(spark) |
| churn_count = df.filter(F.col("event_type") == "subscription_cancelled").count() |
| assert churn_count == 1 |
|
|
| def test_churn_rate_below_100(self, spark): |
| """Churn rate should never exceed 100%.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| active = len(df[df["event_type"] == "invoice_paid"]) |
| churned = len(df[df["event_type"] == "subscription_cancelled"]) |
| rate = (churned / active * 100) if active > 0 else 0 |
| assert rate <= 100 |
| else: |
| df = make_events_df(spark) |
| active = df.filter(F.col("event_type") == "invoice_paid").count() |
| churned = df.filter(F.col("event_type") == "subscription_cancelled").count() |
| rate = (churned / active * 100) if active > 0 else 0 |
| assert rate <= 100 |
|
|
|
|
| class TestEnrichment: |
|
|
| def test_year_month_extraction(self, spark): |
| """event_year and event_month should be correctly extracted.""" |
| if spark is None: |
| df = make_events_df_pandas() |
| df["yr"] = df["event_date"].dt.year |
| df["mo"] = df["event_date"].dt.month |
| jan_rows = len(df[df["mo"] == 1]) |
| assert jan_rows == 4 |
| else: |
| df = make_events_df(spark) |
| enriched = df.withColumn("yr", F.year("event_date")).withColumn("mo", F.month("event_date")) |
| jan_rows = enriched.filter(F.col("mo") == 1).count() |
| assert jan_rows == 4 |
|
|