diff --git a/examples/phase1-2-ttl-enforcement.ts b/examples/phase1-2-ttl-enforcement.ts new file mode 100644 index 0000000..cd575e5 --- /dev/null +++ b/examples/phase1-2-ttl-enforcement.ts @@ -0,0 +1,173 @@ +/** + * Phase 1.2 TTL-at-Fire Enforcement Usage Example + * + * Demonstrates TTL enforcement functionality + * Shows how stale notifications are automatically skipped + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +import { DailyNotification } from '@timesafari/daily-notification-plugin'; + +/** + * Example: Configure TTL enforcement + */ +async function configureTTLEnforcement() { + try { + console.log('Configuring TTL enforcement...'); + + // Configure with TTL settings + await DailyNotification.configure({ + storage: 'shared', + ttlSeconds: 1800, // 30 minutes TTL + prefetchLeadMinutes: 15 + }); + + console.log('✅ TTL enforcement configured (30 minutes)'); + + // Now the plugin will automatically skip notifications with stale content + // Content older than 30 minutes at fire time will not be armed + + } catch (error) { + console.error('❌ TTL configuration failed:', error); + } +} + +/** + * Example: Schedule notification with TTL enforcement + */ +async function scheduleWithTTLEnforcement() { + try { + // Configure TTL enforcement first + await configureTTLEnforcement(); + + // Schedule a notification + await DailyNotification.scheduleDailyNotification({ + url: 'https://api.example.com/daily-content', + time: '09:00', + title: 'Daily Update', + body: 'Your daily notification is ready', + sound: true + }); + + console.log('✅ Notification scheduled with TTL enforcement'); + + // The plugin will now: + // 1. Check content freshness before arming + // 2. Skip notifications with stale content + // 3. Log TTL violations for debugging + // 4. Store violation statistics + + } catch (error) { + console.error('❌ Scheduling with TTL enforcement failed:', error); + } +} + +/** + * Example: Demonstrate TTL violation scenario + */ +async function demonstrateTTLViolation() { + try { + console.log('Demonstrating TTL violation scenario...'); + + // Configure with short TTL for demonstration + await DailyNotification.configure({ + storage: 'shared', + ttlSeconds: 300, // 5 minutes TTL (very short for demo) + prefetchLeadMinutes: 2 + }); + + // Schedule notification + await DailyNotification.scheduleDailyNotification({ + url: 'https://api.example.com/daily-content', + time: '09:00', + title: 'Daily Update', + body: 'Your daily notification is ready' + }); + + console.log('✅ Notification scheduled with 5-minute TTL'); + + // If content is fetched more than 5 minutes before 09:00, + // the notification will be skipped due to TTL violation + + console.log('ℹ️ If content is older than 5 minutes at 09:00, notification will be skipped'); + + } catch (error) { + console.error('❌ TTL violation demonstration failed:', error); + } +} + +/** + * Example: Check TTL violation statistics + */ +async function checkTTLStats() { + try { + console.log('Checking TTL violation statistics...'); + + // Configure TTL enforcement + await DailyNotification.configure({ + storage: 'shared', + ttlSeconds: 1800, // 30 minutes + prefetchLeadMinutes: 15 + }); + + // The plugin automatically tracks TTL violations + // You can check the logs for TTL_VIOLATION entries + // or implement a method to retrieve violation statistics + + console.log('✅ TTL enforcement active - violations will be logged'); + console.log('ℹ️ Check logs for "TTL_VIOLATION" entries'); + + } catch (error) { + console.error('❌ TTL stats check failed:', error); + } +} + +/** + * Example: Different TTL configurations for different use cases + */ +async function configureDifferentTTLScenarios() { + try { + console.log('Configuring different TTL scenarios...'); + + // Scenario 1: Real-time notifications (short TTL) + await DailyNotification.configure({ + storage: 'shared', + ttlSeconds: 300, // 5 minutes + prefetchLeadMinutes: 2 + }); + + console.log('✅ Real-time notifications: 5-minute TTL'); + + // Scenario 2: Daily digest (longer TTL) + await DailyNotification.configure({ + storage: 'shared', + ttlSeconds: 7200, // 2 hours + prefetchLeadMinutes: 30 + }); + + console.log('✅ Daily digest: 2-hour TTL'); + + // Scenario 3: Weekly summary (very long TTL) + await DailyNotification.configure({ + storage: 'shared', + ttlSeconds: 86400, // 24 hours + prefetchLeadMinutes: 60 + }); + + console.log('✅ Weekly summary: 24-hour TTL'); + + } catch (error) { + console.error('❌ TTL scenario configuration failed:', error); + } +} + +// Export examples for use +export { + configureTTLEnforcement, + scheduleWithTTLEnforcement, + demonstrateTTLViolation, + checkTTLStats, + configureDifferentTTLScenarios +}; diff --git a/src/android/DailyNotificationPlugin.java b/src/android/DailyNotificationPlugin.java index 8ffe317..ec05b45 100644 --- a/src/android/DailyNotificationPlugin.java +++ b/src/android/DailyNotificationPlugin.java @@ -101,6 +101,9 @@ public class DailyNotificationPlugin extends Plugin { scheduler = new DailyNotificationScheduler(getContext(), alarmManager); fetcher = new DailyNotificationFetcher(getContext(), storage); + // Initialize TTL enforcer and connect to scheduler + initializeTTLEnforcer(); + // Create notification channel createNotificationChannel(); @@ -285,6 +288,30 @@ public class DailyNotificationPlugin extends Plugin { } } + /** + * Initialize TTL enforcer and connect to scheduler + */ + private void initializeTTLEnforcer() { + try { + Log.d(TAG, "Initializing TTL enforcer"); + + // Create TTL enforcer with current storage mode + DailyNotificationTTLEnforcer ttlEnforcer = new DailyNotificationTTLEnforcer( + getContext(), + database, + useSharedStorage + ); + + // Connect to scheduler + scheduler.setTTLEnforcer(ttlEnforcer); + + Log.i(TAG, "TTL enforcer initialized and connected to scheduler"); + + } catch (Exception e) { + Log.e(TAG, "Error initializing TTL enforcer", e); + } + } + /** * Schedule a daily notification with the specified options * diff --git a/src/android/DailyNotificationScheduler.java b/src/android/DailyNotificationScheduler.java index 13cbb1b..d808b9f 100644 --- a/src/android/DailyNotificationScheduler.java +++ b/src/android/DailyNotificationScheduler.java @@ -36,6 +36,9 @@ public class DailyNotificationScheduler { private final AlarmManager alarmManager; private final ConcurrentHashMap scheduledAlarms; + // TTL enforcement + private DailyNotificationTTLEnforcer ttlEnforcer; + /** * Constructor * @@ -48,6 +51,16 @@ public class DailyNotificationScheduler { this.scheduledAlarms = new ConcurrentHashMap<>(); } + /** + * Set TTL enforcer for freshness validation + * + * @param ttlEnforcer TTL enforcement instance + */ + public void setTTLEnforcer(DailyNotificationTTLEnforcer ttlEnforcer) { + this.ttlEnforcer = ttlEnforcer; + Log.d(TAG, "TTL enforcer set for freshness validation"); + } + /** * Schedule a notification for delivery * @@ -58,6 +71,16 @@ public class DailyNotificationScheduler { try { Log.d(TAG, "Scheduling notification: " + content.getId()); + // TTL validation before arming + if (ttlEnforcer != null) { + if (!ttlEnforcer.validateBeforeArming(content)) { + Log.w(TAG, "Skipping notification due to TTL violation: " + content.getId()); + return false; + } + } else { + Log.w(TAG, "TTL enforcer not set, proceeding without freshness validation"); + } + // Cancel any existing alarm for this notification cancelNotification(content.getId()); diff --git a/src/android/DailyNotificationTTLEnforcer.java b/src/android/DailyNotificationTTLEnforcer.java new file mode 100644 index 0000000..d826967 --- /dev/null +++ b/src/android/DailyNotificationTTLEnforcer.java @@ -0,0 +1,438 @@ +/** + * DailyNotificationTTLEnforcer.java + * + * TTL-at-fire enforcement for notification freshness + * Implements the skip rule: if (T - fetchedAt) > ttlSeconds → skip arming + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import java.util.concurrent.TimeUnit; + +/** + * Enforces TTL-at-fire rules for notification freshness + * + * This class implements the critical freshness enforcement: + * - Before arming for T, if (T − fetchedAt) > ttlSeconds → skip + * - Logs TTL violations for debugging + * - Supports both SQLite and SharedPreferences storage + * - Provides freshness validation before scheduling + */ +public class DailyNotificationTTLEnforcer { + + private static final String TAG = "DailyNotificationTTLEnforcer"; + private static final String LOG_CODE_TTL_VIOLATION = "TTL_VIOLATION"; + + // Default TTL values + private static final long DEFAULT_TTL_SECONDS = 3600; // 1 hour + private static final long MIN_TTL_SECONDS = 60; // 1 minute + private static final long MAX_TTL_SECONDS = 86400; // 24 hours + + private final Context context; + private final DailyNotificationDatabase database; + private final boolean useSharedStorage; + + /** + * Constructor + * + * @param context Application context + * @param database SQLite database (null if using SharedPreferences) + * @param useSharedStorage Whether to use SQLite or SharedPreferences + */ + public DailyNotificationTTLEnforcer(Context context, DailyNotificationDatabase database, boolean useSharedStorage) { + this.context = context; + this.database = database; + this.useSharedStorage = useSharedStorage; + } + + /** + * Check if notification content is fresh enough to arm + * + * @param slotId Notification slot ID + * @param scheduledTime T (slot time) - when notification should fire + * @param fetchedAt When content was fetched + * @return true if content is fresh enough to arm + */ + public boolean isContentFresh(String slotId, long scheduledTime, long fetchedAt) { + try { + long ttlSeconds = getTTLSeconds(); + + // Calculate age at fire time + long ageAtFireTime = scheduledTime - fetchedAt; + long ageAtFireSeconds = TimeUnit.MILLISECONDS.toSeconds(ageAtFireTime); + + boolean isFresh = ageAtFireSeconds <= ttlSeconds; + + if (!isFresh) { + logTTLViolation(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds); + } + + Log.d(TAG, String.format("TTL check for %s: age=%ds, ttl=%ds, fresh=%s", + slotId, ageAtFireSeconds, ttlSeconds, isFresh)); + + return isFresh; + + } catch (Exception e) { + Log.e(TAG, "Error checking content freshness", e); + // Default to allowing arming if check fails + return true; + } + } + + /** + * Check if notification content is fresh enough to arm (using stored fetchedAt) + * + * @param slotId Notification slot ID + * @param scheduledTime T (slot time) - when notification should fire + * @return true if content is fresh enough to arm + */ + public boolean isContentFresh(String slotId, long scheduledTime) { + try { + long fetchedAt = getFetchedAt(slotId); + if (fetchedAt == 0) { + Log.w(TAG, "No fetchedAt found for slot: " + slotId); + return false; + } + + return isContentFresh(slotId, scheduledTime, fetchedAt); + + } catch (Exception e) { + Log.e(TAG, "Error checking content freshness for slot: " + slotId, e); + return false; + } + } + + /** + * Validate freshness before arming notification + * + * @param notificationContent Notification content to validate + * @return true if notification should be armed + */ + public boolean validateBeforeArming(NotificationContent notificationContent) { + try { + String slotId = notificationContent.getId(); + long scheduledTime = notificationContent.getScheduledTime(); + long fetchedAt = notificationContent.getFetchedAt(); + + Log.d(TAG, String.format("Validating freshness before arming: slot=%s, scheduled=%d, fetched=%d", + slotId, scheduledTime, fetchedAt)); + + boolean isFresh = isContentFresh(slotId, scheduledTime, fetchedAt); + + if (!isFresh) { + Log.w(TAG, "Skipping arming due to TTL violation: " + slotId); + return false; + } + + Log.d(TAG, "Content is fresh, proceeding with arming: " + slotId); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error validating freshness before arming", e); + return false; + } + } + + /** + * Get TTL seconds from configuration + * + * @return TTL in seconds + */ + private long getTTLSeconds() { + try { + if (useSharedStorage && database != null) { + return getTTLFromSQLite(); + } else { + return getTTLFromSharedPreferences(); + } + } catch (Exception e) { + Log.e(TAG, "Error getting TTL seconds", e); + return DEFAULT_TTL_SECONDS; + } + } + + /** + * Get TTL from SQLite database + * + * @return TTL in seconds + */ + private long getTTLFromSQLite() { + try { + SQLiteDatabase db = database.getReadableDatabase(); + android.database.Cursor cursor = db.query( + DailyNotificationDatabase.TABLE_NOTIF_CONFIG, + new String[]{DailyNotificationDatabase.COL_CONFIG_V}, + DailyNotificationDatabase.COL_CONFIG_K + " = ?", + new String[]{"ttlSeconds"}, + null, null, null + ); + + long ttlSeconds = DEFAULT_TTL_SECONDS; + if (cursor.moveToFirst()) { + ttlSeconds = Long.parseLong(cursor.getString(0)); + } + cursor.close(); + + // Validate TTL range + ttlSeconds = Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, ttlSeconds)); + + return ttlSeconds; + + } catch (Exception e) { + Log.e(TAG, "Error getting TTL from SQLite", e); + return DEFAULT_TTL_SECONDS; + } + } + + /** + * Get TTL from SharedPreferences + * + * @return TTL in seconds + */ + private long getTTLFromSharedPreferences() { + try { + SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); + long ttlSeconds = prefs.getLong("ttlSeconds", DEFAULT_TTL_SECONDS); + + // Validate TTL range + ttlSeconds = Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, ttlSeconds)); + + return ttlSeconds; + + } catch (Exception e) { + Log.e(TAG, "Error getting TTL from SharedPreferences", e); + return DEFAULT_TTL_SECONDS; + } + } + + /** + * Get fetchedAt timestamp for a slot + * + * @param slotId Notification slot ID + * @return FetchedAt timestamp in milliseconds + */ + private long getFetchedAt(String slotId) { + try { + if (useSharedStorage && database != null) { + return getFetchedAtFromSQLite(slotId); + } else { + return getFetchedAtFromSharedPreferences(slotId); + } + } catch (Exception e) { + Log.e(TAG, "Error getting fetchedAt for slot: " + slotId, e); + return 0; + } + } + + /** + * Get fetchedAt from SQLite database + * + * @param slotId Notification slot ID + * @return FetchedAt timestamp in milliseconds + */ + private long getFetchedAtFromSQLite(String slotId) { + try { + SQLiteDatabase db = database.getReadableDatabase(); + android.database.Cursor cursor = db.query( + DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, + new String[]{DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT}, + DailyNotificationDatabase.COL_CONTENTS_SLOT_ID + " = ?", + new String[]{slotId}, + null, null, + DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT + " DESC", + "1" + ); + + long fetchedAt = 0; + if (cursor.moveToFirst()) { + fetchedAt = cursor.getLong(0); + } + cursor.close(); + + return fetchedAt; + + } catch (Exception e) { + Log.e(TAG, "Error getting fetchedAt from SQLite", e); + return 0; + } + } + + /** + * Get fetchedAt from SharedPreferences + * + * @param slotId Notification slot ID + * @return FetchedAt timestamp in milliseconds + */ + private long getFetchedAtFromSharedPreferences(String slotId) { + try { + SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); + return prefs.getLong("last_fetch_" + slotId, 0); + + } catch (Exception e) { + Log.e(TAG, "Error getting fetchedAt from SharedPreferences", e); + return 0; + } + } + + /** + * Log TTL violation with detailed information + * + * @param slotId Notification slot ID + * @param scheduledTime When notification was scheduled to fire + * @param fetchedAt When content was fetched + * @param ageAtFireSeconds Age of content at fire time + * @param ttlSeconds TTL limit in seconds + */ + private void logTTLViolation(String slotId, long scheduledTime, long fetchedAt, + long ageAtFireSeconds, long ttlSeconds) { + try { + String violationMessage = String.format( + "TTL violation: slot=%s, scheduled=%d, fetched=%d, age=%ds, ttl=%ds", + slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds + ); + + Log.w(TAG, LOG_CODE_TTL_VIOLATION + ": " + violationMessage); + + // Store violation in database or SharedPreferences for analytics + storeTTLViolation(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds); + + } catch (Exception e) { + Log.e(TAG, "Error logging TTL violation", e); + } + } + + /** + * Store TTL violation for analytics + */ + private void storeTTLViolation(String slotId, long scheduledTime, long fetchedAt, + long ageAtFireSeconds, long ttlSeconds) { + try { + if (useSharedStorage && database != null) { + storeTTLViolationInSQLite(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds); + } else { + storeTTLViolationInSharedPreferences(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds); + } + } catch (Exception e) { + Log.e(TAG, "Error storing TTL violation", e); + } + } + + /** + * Store TTL violation in SQLite database + */ + private void storeTTLViolationInSQLite(String slotId, long scheduledTime, long fetchedAt, + long ageAtFireSeconds, long ttlSeconds) { + try { + SQLiteDatabase db = database.getWritableDatabase(); + + // Insert into notif_deliveries with error status + android.content.ContentValues values = new android.content.ContentValues(); + values.put(DailyNotificationDatabase.COL_DELIVERIES_SLOT_ID, slotId); + values.put(DailyNotificationDatabase.COL_DELIVERIES_FIRE_AT, scheduledTime); + values.put(DailyNotificationDatabase.COL_DELIVERIES_STATUS, DailyNotificationDatabase.STATUS_ERROR); + values.put(DailyNotificationDatabase.COL_DELIVERIES_ERROR_CODE, LOG_CODE_TTL_VIOLATION); + values.put(DailyNotificationDatabase.COL_DELIVERIES_ERROR_MESSAGE, + String.format("Content age %ds exceeds TTL %ds", ageAtFireSeconds, ttlSeconds)); + + db.insert(DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES, null, values); + + } catch (Exception e) { + Log.e(TAG, "Error storing TTL violation in SQLite", e); + } + } + + /** + * Store TTL violation in SharedPreferences + */ + private void storeTTLViolationInSharedPreferences(String slotId, long scheduledTime, long fetchedAt, + long ageAtFireSeconds, long ttlSeconds) { + try { + SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + + String violationKey = "ttl_violation_" + slotId + "_" + scheduledTime; + String violationValue = String.format("%d,%d,%d,%d", fetchedAt, ageAtFireSeconds, ttlSeconds, System.currentTimeMillis()); + + editor.putString(violationKey, violationValue); + editor.apply(); + + } catch (Exception e) { + Log.e(TAG, "Error storing TTL violation in SharedPreferences", e); + } + } + + /** + * Get TTL violation statistics + * + * @return Statistics string + */ + public String getTTLViolationStats() { + try { + if (useSharedStorage && database != null) { + return getTTLViolationStatsFromSQLite(); + } else { + return getTTLViolationStatsFromSharedPreferences(); + } + } catch (Exception e) { + Log.e(TAG, "Error getting TTL violation stats", e); + return "Error retrieving TTL violation statistics"; + } + } + + /** + * Get TTL violation statistics from SQLite + */ + private String getTTLViolationStatsFromSQLite() { + try { + SQLiteDatabase db = database.getReadableDatabase(); + android.database.Cursor cursor = db.rawQuery( + "SELECT COUNT(*) FROM " + DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES + + " WHERE " + DailyNotificationDatabase.COL_DELIVERIES_ERROR_CODE + " = ?", + new String[]{LOG_CODE_TTL_VIOLATION} + ); + + int violationCount = 0; + if (cursor.moveToFirst()) { + violationCount = cursor.getInt(0); + } + cursor.close(); + + return String.format("TTL violations: %d", violationCount); + + } catch (Exception e) { + Log.e(TAG, "Error getting TTL violation stats from SQLite", e); + return "Error retrieving TTL violation statistics"; + } + } + + /** + * Get TTL violation statistics from SharedPreferences + */ + private String getTTLViolationStatsFromSharedPreferences() { + try { + SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); + java.util.Map allPrefs = prefs.getAll(); + + int violationCount = 0; + for (String key : allPrefs.keySet()) { + if (key.startsWith("ttl_violation_")) { + violationCount++; + } + } + + return String.format("TTL violations: %d", violationCount); + + } catch (Exception e) { + Log.e(TAG, "Error getting TTL violation stats from SharedPreferences", e); + return "Error retrieving TTL violation statistics"; + } + } +} diff --git a/src/android/DailyNotificationTTLEnforcerTest.java b/src/android/DailyNotificationTTLEnforcerTest.java new file mode 100644 index 0000000..e932331 --- /dev/null +++ b/src/android/DailyNotificationTTLEnforcerTest.java @@ -0,0 +1,217 @@ +/** + * DailyNotificationTTLEnforcerTest.java + * + * Unit tests for TTL-at-fire enforcement functionality + * Tests freshness validation, TTL violation logging, and skip logic + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.test.AndroidTestCase; +import android.test.mock.MockContext; + +import java.util.concurrent.TimeUnit; + +/** + * Unit tests for DailyNotificationTTLEnforcer + * + * Tests the core TTL enforcement functionality including: + * - Freshness validation before arming + * - TTL violation detection and logging + * - Skip logic for stale content + * - Configuration retrieval from storage + */ +public class DailyNotificationTTLEnforcerTest extends AndroidTestCase { + + private DailyNotificationTTLEnforcer ttlEnforcer; + private Context mockContext; + private DailyNotificationDatabase database; + + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Create mock context + mockContext = new MockContext() { + @Override + public android.content.SharedPreferences getSharedPreferences(String name, int mode) { + return getContext().getSharedPreferences(name, mode); + } + }; + + // Create database instance + database = new DailyNotificationDatabase(mockContext); + + // Create TTL enforcer with SQLite storage + ttlEnforcer = new DailyNotificationTTLEnforcer(mockContext, database, true); + } + + @Override + protected void tearDown() throws Exception { + if (database != null) { + database.close(); + } + super.tearDown(); + } + + /** + * Test freshness validation with fresh content + */ + public void testFreshContentValidation() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now + long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); // 5 minutes ago + + boolean isFresh = ttlEnforcer.isContentFresh("test_slot_1", scheduledTime, fetchedAt); + + assertTrue("Content should be fresh (5 min old, scheduled 30 min from now)", isFresh); + } + + /** + * Test freshness validation with stale content + */ + public void testStaleContentValidation() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now + long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago + + boolean isFresh = ttlEnforcer.isContentFresh("test_slot_2", scheduledTime, fetchedAt); + + assertFalse("Content should be stale (2 hours old, exceeds 1 hour TTL)", isFresh); + } + + /** + * Test TTL violation detection + */ + public void testTTLViolationDetection() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago + + // This should trigger a TTL violation + boolean isFresh = ttlEnforcer.isContentFresh("test_slot_3", scheduledTime, fetchedAt); + + assertFalse("Should detect TTL violation", isFresh); + + // Check that violation was logged (we can't easily test the actual logging, + // but we can verify the method returns false as expected) + } + + /** + * Test validateBeforeArming with fresh content + */ + public void testValidateBeforeArmingFresh() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); + + NotificationContent content = new NotificationContent(); + content.setId("test_slot_4"); + content.setScheduledTime(scheduledTime); + content.setFetchedAt(fetchedAt); + content.setTitle("Test Notification"); + content.setBody("Test body"); + + boolean shouldArm = ttlEnforcer.validateBeforeArming(content); + + assertTrue("Should arm fresh content", shouldArm); + } + + /** + * Test validateBeforeArming with stale content + */ + public void testValidateBeforeArmingStale() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); + + NotificationContent content = new NotificationContent(); + content.setId("test_slot_5"); + content.setScheduledTime(scheduledTime); + content.setFetchedAt(fetchedAt); + content.setTitle("Test Notification"); + content.setBody("Test body"); + + boolean shouldArm = ttlEnforcer.validateBeforeArming(content); + + assertFalse("Should not arm stale content", shouldArm); + } + + /** + * Test edge case: content fetched exactly at TTL limit + */ + public void testTTLBoundaryCase() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1); // Exactly 1 hour ago (TTL limit) + + boolean isFresh = ttlEnforcer.isContentFresh("test_slot_6", scheduledTime, fetchedAt); + + assertTrue("Content at TTL boundary should be considered fresh", isFresh); + } + + /** + * Test edge case: content fetched just over TTL limit + */ + public void testTTLBoundaryCaseOver() { + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1) - TimeUnit.SECONDS.toMillis(1); // 1 hour + 1 second ago + + boolean isFresh = ttlEnforcer.isContentFresh("test_slot_7", scheduledTime, fetchedAt); + + assertFalse("Content just over TTL limit should be considered stale", isFresh); + } + + /** + * Test TTL violation statistics + */ + public void testTTLViolationStats() { + // Generate some TTL violations + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); + + // Trigger TTL violations + ttlEnforcer.isContentFresh("test_slot_8", scheduledTime, fetchedAt); + ttlEnforcer.isContentFresh("test_slot_9", scheduledTime, fetchedAt); + + String stats = ttlEnforcer.getTTLViolationStats(); + + assertNotNull("TTL violation stats should not be null", stats); + assertTrue("Stats should contain violation count", stats.contains("violations")); + } + + /** + * Test error handling with invalid parameters + */ + public void testErrorHandling() { + // Test with null slot ID + boolean result = ttlEnforcer.isContentFresh(null, System.currentTimeMillis(), System.currentTimeMillis()); + assertFalse("Should handle null slot ID gracefully", result); + + // Test with invalid timestamps + result = ttlEnforcer.isContentFresh("test_slot_10", 0, 0); + assertTrue("Should handle invalid timestamps gracefully", result); + } + + /** + * Test TTL configuration retrieval + */ + public void testTTLConfiguration() { + // Test that TTL enforcer can retrieve configuration + // This is indirectly tested through the freshness checks + long currentTime = System.currentTimeMillis(); + long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); + long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(30); // 30 minutes ago + + boolean isFresh = ttlEnforcer.isContentFresh("test_slot_11", scheduledTime, fetchedAt); + + // Should be fresh (30 min < 1 hour TTL) + assertTrue("Should retrieve TTL configuration correctly", isFresh); + } +}