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.
347 lines
15 KiB
Swift
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
|
|
|