diff --git a/scripts/data/churn-analysis-fixtures.json b/scripts/data/churn-analysis-fixtures.json new file mode 100644 index 0000000..829f8cc --- /dev/null +++ b/scripts/data/churn-analysis-fixtures.json @@ -0,0 +1,151 @@ +{ + "$schema": "https://json-schema.org/draft/2027/schema", + "title": "Churn Analysis Golden Fixtures", + "description": "Canonical input events and expected outputs for churn-analysis.ts deterministic tests. Each fixture is a self-contained scenario covering a specific metric or edge case.", + "fixtures": [ + { + "name": "cohort-retention-new-logic", + "description": "Tests cohort retention (30d/90d) with 'new' resubscription logic. Covers multiple cohorts, resubscriptions, and insufficient data guards.", + "referenceTime": 1781481600, + "events": [ + { "eventName": "subscribed", "user": "user1", "merchant": "merchA", "timestamp": 1768003200 }, + { "eventName": "subscribed", "user": "user2", "merchant": "merchA", "timestamp": 1768435200 }, + { "eventName": "cancelled", "user": "user2", "timestamp": 1768867200 }, + { "eventName": "subscribed", "user": "user3", "merchant": "merchB", "timestamp": 1768867200 }, + { "eventName": "cancelled", "user": "user3", "timestamp": 1773532800 }, + { "eventName": "subscribed", "user": "user5", "merchant": "merchC", "timestamp": 1767571200 }, + { "eventName": "cancelled", "user": "user5", "timestamp": 1768435200 }, + { "eventName": "subscribed", "user": "user5", "merchant": "merchC", "timestamp": 1769300000 }, + { "eventName": "cancelled", "user": "user5", "timestamp": 1772236800 }, + { "eventName": "subscribed", "user": "user4", "merchant": "merchB", "timestamp": 1775433600 } + ], + "expected": { + "cohorts": [ + { "cohort": "2026-01", "initial_size": 5, "active_30_days": 3, "active_90_days": 1, "retention_rate_30d": "60.00%", "retention_rate_90d": "20.00%" }, + { "cohort": "2026-04", "initial_size": 1, "active_30_days": 1, "active_90_days": "insufficient data", "retention_rate_30d": "100.00%", "retention_rate_90d": "insufficient data" } + ], + "projection": { "average_monthly_churn_rate": "20.00%", "current_active_subscribers": 2, "projected_churn_count": "0.40" } + } + }, + { + "name": "cohort-retention-retention-logic", + "description": "Tests cohort retention with 'retention' resubscription logic (first-subscribe user tracking). Resubscriptions do not create new lifespans.", + "referenceTime": 1781481600, + "events": [ + { "eventName": "subscribed", "user": "user1", "merchant": "merchA", "timestamp": 1768003200 }, + { "eventName": "subscribed", "user": "user2", "merchant": "merchA", "timestamp": 1768435200 }, + { "eventName": "cancelled", "user": "user2", "timestamp": 1768867200 }, + { "eventName": "subscribed", "user": "user3", "merchant": "merchB", "timestamp": 1768867200 }, + { "eventName": "cancelled", "user": "user3", "timestamp": 1773532800 }, + { "eventName": "subscribed", "user": "user5", "merchant": "merchC", "timestamp": 1767571200 }, + { "eventName": "cancelled", "user": "user5", "timestamp": 1768435200 }, + { "eventName": "subscribed", "user": "user5", "merchant": "merchC", "timestamp": 1769300000 }, + { "eventName": "cancelled", "user": "user5", "timestamp": 1772236800 }, + { "eventName": "subscribed", "user": "user4", "merchant": "merchB", "timestamp": 1775433600 } + ], + "expected": { + "cohorts": [ + { "cohort": "2026-01", "initial_size": 4, "active_30_days": 3, "active_90_days": 1, "retention_rate_30d": "75.00%", "retention_rate_90d": "25.00%" }, + { "cohort": "2026-04", "initial_size": 1, "active_30_days": 1, "active_90_days": "insufficient data", "retention_rate_30d": "100.00%", "retention_rate_90d": "insufficient data" } + ], + "projection": { "average_monthly_churn_rate": "12.50%", "current_active_subscribers": 2, "projected_churn_count": "0.25" } + } + }, + { + "name": "merchant-churn-breakdown", + "description": "Tests merchant-level churn breakdown. Filters out single-subscriber merchants. Sorts top 5 by churn rate descending.", + "referenceTime": 500, + "events": [ + { "eventName": "subscribed", "user": "user1", "merchant": "merchA", "timestamp": 100 }, + { "eventName": "cancelled", "user": "user1", "timestamp": 150 }, + { "eventName": "subscribed", "user": "user1", "merchant": "merchB", "timestamp": 200 }, + { "eventName": "subscribed", "user": "user2", "merchant": "merchB", "timestamp": 210 }, + { "eventName": "subscribed", "user": "user3", "merchant": "merchB", "timestamp": 220 }, + { "eventName": "cancelled", "user": "user2", "timestamp": 250 }, + { "eventName": "cancelled", "user": "user3", "timestamp": 260 }, + { "eventName": "subscribed", "user": "user1", "merchant": "merchC", "timestamp": 300 }, + { "eventName": "subscribed", "user": "user2", "merchant": "merchC", "timestamp": 310 }, + { "eventName": "subscribed", "user": "user4", "merchant": "merchD", "timestamp": 400 }, + { "eventName": "cancelled", "user": "user4", "timestamp": 450 } + ], + "expected": { + "top_merchants_churn": [ + { "merchant_id": "merchB", "subscribers": 3, "cancellations": 2, "churn_rate": "66.67%" }, + { "merchant_id": "merchC", "subscribers": 2, "cancellations": 0, "churn_rate": "0.00%" } + ] + } + }, + { + "name": "churn-projection-multiple-completed-cohorts", + "description": "Tests historical churn projection across multiple completed cohorts with varying churn rates. Validates average churn rate calculation and projection formula.", + "referenceTime": 1781481600, + "events": [ + { "eventName": "subscribed", "user": "user1", "merchant": "merchM", "timestamp": 1767571200 }, + { "eventName": "subscribed", "user": "user2", "merchant": "merchM", "timestamp": 1767571210 }, + { "eventName": "subscribed", "user": "user3", "merchant": "merchM", "timestamp": 1767571220 }, + { "eventName": "subscribed", "user": "user4", "merchant": "merchM", "timestamp": 1767571230 }, + { "eventName": "cancelled", "user": "user1", "timestamp": 1768435200 }, + { "eventName": "cancelled", "user": "user2", "timestamp": 1768867200 }, + { "eventName": "subscribed", "user": "feb_user_0", "merchant": "merchM", "timestamp": 1770249600 }, + { "eventName": "subscribed", "user": "feb_user_1", "merchant": "merchM", "timestamp": 1770249601 }, + { "eventName": "subscribed", "user": "feb_user_2", "merchant": "merchM", "timestamp": 1770249602 }, + { "eventName": "subscribed", "user": "feb_user_3", "merchant": "merchM", "timestamp": 1770249603 }, + { "eventName": "subscribed", "user": "feb_user_4", "merchant": "merchM", "timestamp": 1770249604 }, + { "eventName": "subscribed", "user": "feb_user_5", "merchant": "merchM", "timestamp": 1770249605 }, + { "eventName": "subscribed", "user": "feb_user_6", "merchant": "merchM", "timestamp": 1770249606 }, + { "eventName": "subscribed", "user": "feb_user_7", "merchant": "merchM", "timestamp": 1770249607 }, + { "eventName": "subscribed", "user": "feb_user_8", "merchant": "merchM", "timestamp": 1770249608 }, + { "eventName": "subscribed", "user": "feb_user_9", "merchant": "merchM", "timestamp": 1770249609 }, + { "eventName": "cancelled", "user": "feb_user_0", "timestamp": 1770681600 }, + { "eventName": "cancelled", "user": "feb_user_1", "timestamp": 1771113600 } + ], + "expected": { + "projection": { "average_monthly_churn_rate": "35.00%", "current_active_subscribers": 10, "projected_churn_count": "3.50" } + } + }, + { + "name": "grace-period-cancelled-with-refund", + "description": "Tests that 'cancelled_with_refund' events are treated identically to 'cancelled' for churn metrics (grace lapses).", + "referenceTime": 1781481600, + "events": [ + { "eventName": "subscribed", "user": "userA", "merchant": "merchX", "timestamp": 1768003200 }, + { "eventName": "cancelled_with_refund", "user": "userA", "timestamp": 1768867200 }, + { "eventName": "subscribed", "user": "userB", "merchant": "merchX", "timestamp": 1768003210 }, + { "eventName": "cancelled", "user": "userB", "timestamp": 1768867210 } + ], + "expected": { + "cohorts": [ + { "cohort": "2026-01", "initial_size": 2, "active_30_days": 0, "active_90_days": 0, "retention_rate_30d": "0.00%", "retention_rate_90d": "0.00%" } + ], + "projection": { "average_monthly_churn_rate": "100.00%", "current_active_subscribers": 0, "projected_churn_count": "0.00" } + } + }, + { + "name": "empty-and-single-event-edge-cases", + "description": "Tests empty event list and single event edge cases produce valid reports with zero/insufficient data.", + "referenceTime": 1781481600, + "events": [], + "expected": { + "cohorts": [], + "top_merchants_churn": [], + "projection": { "average_monthly_churn_rate": "0.00%", "current_active_subscribers": 0, "projected_churn_count": "0.00" } + } + }, + { + "name": "single-subscriber-merchant-filtered", + "description": "Verifies merchants with <= 1 unique subscriber are excluded from top_merchants_churn to avoid statistical skew.", + "referenceTime": 1000, + "events": [ + { "eventName": "subscribed", "user": "user1", "merchant": "singleSub", "timestamp": 100 }, + { "eventName": "subscribed", "user": "user2", "merchant": "twoSubs", "timestamp": 110 }, + { "eventName": "subscribed", "user": "user3", "merchant": "twoSubs", "timestamp": 120 }, + { "eventName": "cancelled", "user": "user3", "timestamp": 130 } + ], + "expected": { + "top_merchants_churn": [ + { "merchant_id": "twoSubs", "subscribers": 2, "cancellations": 1, "churn_rate": "50.00%" } + ] + } + } + ] +} \ No newline at end of file diff --git a/scripts/package.json b/scripts/package.json index fd708b3..00fc539 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -40,6 +40,7 @@ "lint": "tsc --noEmit --strict --noUnusedLocals --noUnusedParameters", "test:event-dedup": "tsx test-event-dedup-integration.ts", "test:renewal-forecast": "tsx test-renewal-forecast.ts", + "test": "tsx test-churn-analysis.ts" "test:rpc-failover": "tsx test-rpc-failover.ts" "test:renewal-forecast": "tsx test-renewal-forecast.ts" }, diff --git a/scripts/test-churn-analysis.ts b/scripts/test-churn-analysis.ts index 3345958..3db5a8b 100644 --- a/scripts/test-churn-analysis.ts +++ b/scripts/test-churn-analysis.ts @@ -1,5 +1,14 @@ +import { calculateChurnAnalysis, ChurnAnalysisReport } from "./churn-analysis.js"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; -import { calculateChurnAnalysis } from "./churn-analysis.js"; + +const __dirname = resolve(fileURLToPath(import.meta.url), ".."); +const FIXTURES_PATH = resolve(__dirname, "data/churn-analysis-fixtures.json"); + +// Load golden fixtures +const fixtures = JSON.parse(readFileSync(FIXTURES_PATH, "utf8")).fixtures; // Utility to assert values in tests function assertEquals(actual: any, expected: any, message: string) { @@ -13,6 +22,109 @@ function assertEquals(actual: any, expected: any, message: string) { } } +function deepEquals(actual: any, expected: any): boolean { + return JSON.stringify(actual) === JSON.stringify(expected); +} + +function assertDeepEquals(actual: any, expected: any, message: string) { + if (!deepEquals(actual, expected)) { + console.error(`Assertion Failed: ${message}`); + console.error(` Expected: ${JSON.stringify(expected, null, 2)}`); + console.error(` Actual: ${JSON.stringify(actual, null, 2)}`); + process.exit(1); + } else { + console.log(`[PASS] ${message}`); + } +} + +function runFixture(fixture: typeof fixtures[0], logic: "new" | "retention") { + const report = calculateChurnAnalysis(fixture.events, logic, fixture.referenceTime); + + // Validate cohorts if expected + if (fixture.expected.cohorts) { + for (const expCohort of fixture.expected.cohorts) { + const actCohort = report.cohorts.find((c) => c.cohort === expCohort.cohort); + assertEquals( + actCohort?.initial_size, + expCohort.initial_size, + `${fixture.name} [${logic}] cohort ${expCohort.cohort} initial_size` + ); + assertEquals( + actCohort?.active_30_days, + expCohort.active_30_days, + `${fixture.name} [${logic}] cohort ${expCohort.cohort} active_30_days` + ); + assertEquals( + actCohort?.active_90_days, + expCohort.active_90_days, + `${fixture.name} [${logic}] cohort ${expCohort.cohort} active_90_days` + ); + assertEquals( + actCohort?.retention_rate_30d, + expCohort.retention_rate_30d, + `${fixture.name} [${logic}] cohort ${expCohort.cohort} retention_rate_30d` + ); + assertEquals( + actCohort?.retention_rate_90d, + expCohort.retention_rate_90d, + `${fixture.name} [${logic}] cohort ${expCohort.cohort} retention_rate_90d` + ); + } + } + + // Validate projection if expected + if (fixture.expected.projection) { + assertEquals( + report.projection.average_monthly_churn_rate, + fixture.expected.projection.average_monthly_churn_rate, + `${fixture.name} [${logic}] avg monthly churn rate` + ); + assertEquals( + report.projection.current_active_subscribers, + fixture.expected.projection.current_active_subscribers, + `${fixture.name} [${logic}] current active subscribers` + ); + assertEquals( + report.projection.projected_churn_count, + fixture.expected.projection.projected_churn_count, + `${fixture.name} [${logic}] projected churn count` + ); + } + + // Validate merchant churn if expected + if (fixture.expected.top_merchants_churn) { + const expMerchants = fixture.expected.top_merchants_churn; + const actMerchants = report.top_merchants_churn; + + assertEquals( + actMerchants.length, + expMerchants.length, + `${fixture.name} [${logic}] merchant count` + ); + + for (let i = 0; i < expMerchants.length; i++) { + assertEquals( + actMerchants[i]?.merchant_id, + expMerchants[i].merchant_id, + `${fixture.name} [${logic}] merchant ${i} id` + ); + assertEquals( + actMerchants[i]?.subscribers, + expMerchants[i].subscribers, + `${fixture.name} [${logic}] merchant ${i} subscribers` + ); + assertEquals( + actMerchants[i]?.cancellations, + expMerchants[i].cancellations, + `${fixture.name} [${logic}] merchant ${i} cancellations` + ); + assertEquals( + actMerchants[i]?.churn_rate, + expMerchants[i].churn_rate, + `${fixture.name} [${logic}] merchant ${i} churn rate` + ); + } + } function testCohortAndResubscriptionLogic() { console.log( "\n--- Running Churn Analysis Cohort and Resubscription Logic Tests ---", @@ -280,18 +392,60 @@ function testMerchantChurnBreakdown() { assertEquals(merchC?.churn_rate, "0.00%", "Merchant C churn rate"); } -function testChurnProjection() { - console.log("\n--- Running Churn Projection Tests ---"); +function runAllFixtures() { + console.log("=========================================="); + console.log(" CHURN ANALYSIS GOLDEN FIXTURE TESTS "); + console.log("==========================================\n"); + + for (const fixture of fixtures) { + console.log(`\n--- Fixture: ${fixture.name} ---`); + console.log(` ${fixture.description}`); + + // Test both logic modes unless fixture is logic-specific + const logics: ("new" | "retention")[] = fixture.name.includes("new-logic") + ? ["new"] + : fixture.name.includes("retention-logic") + ? ["retention"] + : ["new", "retention"]; + + for (const logic of logics) { + console.log(` Testing logic: '${logic}'`); + runFixture(fixture, logic); + } + } + + console.log("\n=========================================="); + console.log(" ALL GOLDEN FIXTURE TESTS PASSED! "); + console.log("=========================================="); +} - // Mock events with 2 completed cohorts: - // Cohort Jan: size 4, active30 is 2. Monthly churn rate = 50% - // Cohort Feb: size 10, active30 is 8. Monthly churn rate = 20% - // Average monthly churn rate = (50% + 20%) / 2 = 35% - // Currently active subscribers = 8. - // Projected churn for next month = 8 * 35% = 2.80 - const REF_TIME = 1781481600; // 2026-06-15 +// Additional standalone tests for specific edge cases not covered by fixtures +function runAdditionalTests() { + console.log("\n--- Additional Edge Case Tests ---"); + // Test: Data source reporting (sqlite vs rpc) - already covered by integration + // Test: CSV output format const events = [ + { eventName: "subscribed", user: "user1", merchant: "merchA", timestamp: 1768003200 }, + { eventName: "cancelled", user: "user1", timestamp: 1768867200 }, + ]; + const report = calculateChurnAnalysis(events, "new", 1781481600); + + // Verify report structure completeness + assertEquals(typeof report.generated_at, "string", "Report has generated_at"); + assertEquals(typeof report.reference_time, "string", "Report has reference_time"); + assertEquals(["sqlite", "rpc"].includes(report.data_source), true, "Report has valid data_source"); + assertEquals(["new", "retention"].includes(report.resubscription_logic), true, "Report has valid resubscription_logic"); + assertEquals(Array.isArray(report.cohorts), true, "Report has cohorts array"); + assertEquals(Array.isArray(report.top_merchants_churn), true, "Report has top_merchants_churn array"); + assertEquals(typeof report.projection, "object", "Report has projection object"); + + // Verify projection fields + assertEquals(typeof report.projection.average_monthly_churn_rate, "string", "Projection has avg churn rate string"); + assertEquals(typeof report.projection.current_active_subscribers, "number", "Projection has active count"); + assertEquals(typeof report.projection.projected_churn_count, "string", "Projection has projected churn string"); + + console.log("[PASS] Report structure validation"); // Jan cohort (Starts Jan 05: 1767571200) { eventName: "subscribed", @@ -375,8 +529,8 @@ function testChurnProjection() { ); } -function testDatabaseAndRpcFallback() { - console.log("\n--- Running SQLite and RPC Fallback Configuration Tests ---"); +function runDatabaseAndRpcFallbackTests() { + console.log("\n--- Database and RPC Fallback Configuration Tests ---"); // Create an in-memory SQLite database to test real DatabaseSync querying const db = new DatabaseSync(":memory:"); @@ -397,6 +551,11 @@ function testDatabaseAndRpcFallback() { n: number; }; assertEquals(row.n, 1, "Mock SQLite event inserted correctly"); + + // The report must declare which data source fed it, so callers can tell a + // real RPC-backed run from a fallback/empty one. + const report = calculateChurnAnalysis([], "new", 1781481600); + assertEquals(["sqlite", "rpc"].includes(report.data_source), true, "Report advertises sqlite/rpc data source"); db.close(); } @@ -405,14 +564,13 @@ function runAllTests() { console.log(" PAYFLOW CHURN ANALYSIS TEST SUITE "); console.log("=========================================="); - testCohortAndResubscriptionLogic(); - testMerchantChurnBreakdown(); - testChurnProjection(); - testDatabaseAndRpcFallback(); + runAllFixtures(); + runAdditionalTests(); + runDatabaseAndRpcFallbackTests(); console.log("\n=========================================="); console.log(" ALL TESTS COMPLETED SUCCESSFULLY! "); console.log("=========================================="); } -runAllTests(); +runAllTests(); \ No newline at end of file