Files
daily-notification-plugin/ios/Tests/DailyNotificationReactivationManagerTests.swift
Matthew a90d08c425 feat(ios): add Core Data DAO layer and unit tests
Implement comprehensive data access layer for Core Data entities:

- Add NotificationContentDAO, NotificationDeliveryDAO, and NotificationConfigDAO
  with full CRUD operations and query helpers
- Add DailyNotificationDataConversions utility for type conversions
  (Date ↔ Int64, Int ↔ Int32, JSON, optional strings)
- Update PersistenceController with entity verification and migration policies
- Add comprehensive unit tests for all DAO classes and data conversions
- Update Core Data model with NotificationContent, NotificationDelivery,
  and NotificationConfig entities (relationships and indexes)
- Integrate ReactivationManager into DailyNotificationPlugin.load()

DAO Features:
- Create/Insert methods with dictionary support
- Read/Query methods with predicates (by timesafariDid, notificationType,
  scheduledTime range, deliveryStatus, etc.)
- Update methods (touch, updateDeliveryStatus, recordUserInteraction)
- Delete methods (by ID, by key, delete all)
- Relationship management (NotificationContent ↔ NotificationDelivery)
- Cascade delete support

Test Coverage:
- 328 lines: DailyNotificationDataConversionsTests (time, numeric, string, JSON)
- 490 lines: NotificationContentDAOTests (CRUD, queries, updates)
- 415 lines: NotificationDeliveryDAOTests (CRUD, relationships, cascade delete)
- 412 lines: NotificationConfigDAOTests (CRUD, queries, active filtering)

All tests use in-memory Core Data stack for isolation and speed.

Completes sections 4.4, 4.5, and 6.0 of iOS implementation checklist.
2025-12-09 02:23:05 -08:00

347 lines
15 KiB
Swift

//
// DailyNotificationReactivationManagerTests.swift
// DailyNotificationPluginTests
//
// Created by Matthew Raymer on 2025-12-08
// Copyright © 2025 TimeSafari. All rights reserved.
//
import XCTest
import UserNotifications
@testable import DailyNotificationPlugin
/**
* Unit tests for DailyNotificationReactivationManager
*
* Tests all recovery scenarios: cold start, termination, boot, warm start
*/
class DailyNotificationReactivationManagerTests: XCTestCase {
var reactivationManager: DailyNotificationReactivationManager!
var database: DailyNotificationDatabase!
var storage: DailyNotificationStorage!
var scheduler: DailyNotificationScheduler!
var notificationCenter: UNUserNotificationCenter!
override func setUp() {
super.setUp()
// Use real notification center for testing
notificationCenter = UNUserNotificationCenter.current()
// Create real instances with test database paths
let testDbPath = NSTemporaryDirectory().appending("test_reactivation_db_\(UUID().uuidString).sqlite")
database = DailyNotificationDatabase(path: testDbPath)
storage = DailyNotificationStorage(databasePath: testDbPath)
scheduler = DailyNotificationScheduler()
// Create reactivation manager
reactivationManager = DailyNotificationReactivationManager(
database: database,
storage: storage,
scheduler: scheduler
)
// Clear UserDefaults for clean test state
UserDefaults.standard.removeObject(forKey: "DNP_LAST_LAUNCH_TIME")
}
override func tearDown() {
reactivationManager = nil
database = nil
storage = nil
scheduler = nil
notificationCenter = nil
// Clean up UserDefaults
UserDefaults.standard.removeObject(forKey: "DNP_LAST_LAUNCH_TIME")
// Clean up test database files
let fileManager = FileManager.default
let tempDir = NSTemporaryDirectory()
if let files = try? fileManager.contentsOfDirectory(atPath: tempDir) {
for file in files where file.hasPrefix("test_reactivation_db") {
try? fileManager.removeItem(atPath: tempDir.appending(file))
}
}
super.tearDown()
}
// MARK: - Scenario Detection Tests
func testDetectScenario_None_EmptyStorage() async throws {
// Given: Empty storage (no notifications added)
// Storage is already empty from setUp
// When: Detect scenario
let scenario = try await reactivationManager.detectScenario()
// Then: Should return .none
XCTAssertEqual(scenario, .none, "Empty storage should return .none scenario")
}
func testDetectScenario_ColdStart_Mismatch() async throws {
// Given: Storage has notifications but notification center doesn't
let notification1 = createTestNotification(id: "test-1", scheduledTime: futureTime())
storage.saveNotificationContent(notification1)
// Clear notification center
notificationCenter.removeAllPendingNotificationRequests()
// When: Detect scenario
let scenario = try await reactivationManager.detectScenario()
// Then: Should return .coldStart (or .termination if no pending)
XCTAssertTrue(scenario == .coldStart || scenario == .termination,
"Mismatch should return .coldStart or .termination")
}
func testDetectScenario_WarmStart_Match() async throws {
// Given: Storage and notification center have matching notifications
let notification1 = createTestNotification(id: "test-1", scheduledTime: futureTime())
storage.saveNotificationContent(notification1)
// Schedule notification in notification center
let content = UNMutableNotificationContent()
content.title = notification1.title ?? "Test"
content.body = notification1.body ?? "Test"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 60, repeats: false)
let request = UNNotificationRequest(identifier: notification1.id, content: content, trigger: trigger)
try await notificationCenter.add(request)
// When: Detect scenario
let scenario = try await reactivationManager.detectScenario()
// Then: Should return .warmStart
XCTAssertEqual(scenario, .warmStart, "Matching notifications should return .warmStart")
// Cleanup
notificationCenter.removeAllPendingNotificationRequests()
}
func testDetectScenario_Termination_NoPending() async throws {
// Given: Storage has notifications but notification center is empty
let notification1 = createTestNotification(id: "test-1", scheduledTime: futureTime())
storage.saveNotificationContent(notification1)
// Clear notification center
notificationCenter.removeAllPendingNotificationRequests()
// When: Detect scenario
let scenario = try await reactivationManager.detectScenario()
// Then: Should return .termination
XCTAssertEqual(scenario, .termination, "No pending notifications with storage should return .termination")
}
// MARK: - Boot Detection Tests
func testDetectBootScenario_FirstLaunch_ReturnsFalse() {
// Given: No last launch time (first launch)
UserDefaults.standard.removeObject(forKey: "DNP_LAST_LAUNCH_TIME")
// When: Detect boot scenario
let isBoot = reactivationManager.detectBootScenario()
// Then: Should return false
XCTAssertFalse(isBoot, "First launch should not be detected as boot")
}
func testDetectBootScenario_RecentLaunch_ReturnsFalse() {
// Given: Recent launch time (not a boot)
let recentTime = Date().timeIntervalSince1970 - 300 // 5 minutes ago
UserDefaults.standard.set(recentTime, forKey: "DNP_LAST_LAUNCH_TIME")
// When: Detect boot scenario
let isBoot = reactivationManager.detectBootScenario()
// Then: Should return false
XCTAssertFalse(isBoot, "Recent launch should not be detected as boot")
}
func testDetectBootScenario_BootDetected_ReturnsTrue() {
// Given: Last launch time is far in past (simulating boot)
let oldTime = Date().timeIntervalSince1970 - 3600 // 1 hour ago
UserDefaults.standard.set(oldTime, forKey: "DNP_LAST_LAUNCH_TIME")
// Mock system uptime to be less than time since last launch
// Note: This is a simplified test - in real scenario, ProcessInfo.systemUptime would be small after boot
// When: Detect boot scenario
// Since we can't easily mock ProcessInfo.systemUptime, we'll test the logic
// by checking if the method handles the case correctly
let isBoot = reactivationManager.detectBootScenario()
// Then: May return true if system uptime is actually small (real device/simulator state)
// This test verifies the method doesn't crash
XCTAssertNotNil(isBoot, "Boot detection should not crash")
}
// MARK: - Missed Notification Detection Tests
func testDetectMissedNotifications_PastScheduledTime() async throws {
// Given: Notification with past scheduled time
let pastTime = Int64(Date().timeIntervalSince1970 * 1000) - 3600000 // 1 hour ago
let notification = createTestNotification(id: "missed-1", scheduledTime: pastTime)
storage.saveNotificationContent(notification)
// When: Detect missed notifications
let missed = try await reactivationManager.detectMissedNotifications(currentTime: Date())
// Then: Should detect the missed notification
XCTAssertEqual(missed.count, 1, "Should detect 1 missed notification")
XCTAssertEqual(missed.first?.id, "missed-1", "Should detect correct notification")
}
func testDetectMissedNotifications_FutureScheduledTime() async throws {
// Given: Notification with future scheduled time
let futureTime = Int64(Date().timeIntervalSince1970 * 1000) + 3600000 // 1 hour from now
let notification = createTestNotification(id: "future-1", scheduledTime: futureTime)
storage.saveNotificationContent(notification)
// When: Detect missed notifications
let missed = try await reactivationManager.detectMissedNotifications(currentTime: Date())
// Then: Should not detect as missed
XCTAssertEqual(missed.count, 0, "Should not detect future notifications as missed")
}
func testDetectMissedNotifications_MixedTimes() async throws {
// Given: Mix of past and future notifications
let pastTime = Int64(Date().timeIntervalSince1970 * 1000) - 3600000
let futureTime = Int64(Date().timeIntervalSince1970 * 1000) + 3600000
let pastNotification = createTestNotification(id: "past-1", scheduledTime: pastTime)
let futureNotification = createTestNotification(id: "future-1", scheduledTime: futureTime)
storage.saveNotificationContent(pastNotification)
storage.saveNotificationContent(futureNotification)
// When: Detect missed notifications
let missed = try await reactivationManager.detectMissedNotifications(currentTime: Date())
// Then: Should only detect past notification
XCTAssertEqual(missed.count, 1, "Should detect only past notification")
XCTAssertEqual(missed.first?.id, "past-1", "Should detect correct notification")
}
// MARK: - Future Notification Verification Tests
func testVerifyFutureNotifications_AllScheduled() async throws {
// Given: Future notifications in storage and notification center
let futureTime = Int64(Date().timeIntervalSince1970 * 1000) + 3600000
let notification = createTestNotification(id: "future-1", scheduledTime: futureTime)
storage.saveNotificationContent(notification)
// Schedule in notification center
let content = UNMutableNotificationContent()
content.title = notification.title ?? "Test"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger)
try await notificationCenter.add(request)
// When: Verify future notifications
let result = try await reactivationManager.verifyFutureNotifications()
// Then: Should verify all are scheduled
XCTAssertEqual(result.totalSchedules, 1, "Should have 1 future schedule")
XCTAssertEqual(result.notificationsFound, 1, "Should find 1 scheduled notification")
XCTAssertEqual(result.notificationsMissing, 0, "Should have 0 missing notifications")
// Cleanup
notificationCenter.removeAllPendingNotificationRequests()
}
func testVerifyFutureNotifications_SomeMissing() async throws {
// Given: Future notifications in storage but not all in notification center
let futureTime = Int64(Date().timeIntervalSince1970 * 1000) + 3600000
let notification1 = createTestNotification(id: "future-1", scheduledTime: futureTime)
let notification2 = createTestNotification(id: "future-2", scheduledTime: futureTime + 3600000)
storage.saveNotificationContent(notification1)
storage.saveNotificationContent(notification2)
// Only schedule one in notification center
let content = UNMutableNotificationContent()
content.title = notification1.title ?? "Test"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(identifier: notification1.id, content: content, trigger: trigger)
try await notificationCenter.add(request)
// When: Verify future notifications
let result = try await reactivationManager.verifyFutureNotifications()
// Then: Should detect missing notification
XCTAssertEqual(result.totalSchedules, 2, "Should have 2 future schedules")
XCTAssertEqual(result.notificationsFound, 1, "Should find 1 scheduled notification")
XCTAssertEqual(result.notificationsMissing, 1, "Should have 1 missing notification")
XCTAssertTrue(result.missingIds.contains("future-2"), "Should identify missing notification")
// Cleanup
notificationCenter.removeAllPendingNotificationRequests()
}
// MARK: - Recovery Result Tests
func testRecoveryResult_Initialization() {
// Given: Recovery result data
let result = RecoveryResult(
missedCount: 2,
rescheduledCount: 3,
verifiedCount: 5,
errors: 1
)
// Then: Should have correct values
XCTAssertEqual(result.missedCount, 2)
XCTAssertEqual(result.rescheduledCount, 3)
XCTAssertEqual(result.verifiedCount, 5)
XCTAssertEqual(result.errors, 1)
}
func testVerificationResult_Initialization() {
// Given: Verification result data
let result = VerificationResult(
totalSchedules: 10,
notificationsFound: 8,
notificationsMissing: 2,
missingIds: ["id-1", "id-2"]
)
// Then: Should have correct values
XCTAssertEqual(result.totalSchedules, 10)
XCTAssertEqual(result.notificationsFound, 8)
XCTAssertEqual(result.notificationsMissing, 2)
XCTAssertEqual(result.missingIds.count, 2)
}
// MARK: - Helper Methods
private func createTestNotification(id: String, scheduledTime: Int64) -> NotificationContent {
return NotificationContent(
id: id,
title: "Test Notification",
body: "Test body",
scheduledTime: scheduledTime,
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: nil,
payload: nil,
etag: nil
)
}
private func futureTime() -> Int64 {
return Int64(Date().timeIntervalSince1970 * 1000) + 3600000 // 1 hour from now
}
}
// MARK: - Mock Classes
// Note: We use real instances of DailyNotificationDatabase, DailyNotificationStorage, and DailyNotificationScheduler
// with test database paths for testing. This provides more realistic testing while still being isolated.
// Note: Methods are now internal in ReactivationManager, so they can be tested directly