// // NotificationContentDAOTests.swift // DailyNotificationPluginTests // // Created by Matthew Raymer on 2025-12-08 // Copyright © 2025 TimeSafari. All rights reserved. // import XCTest import CoreData @testable import DailyNotificationPlugin /** * Unit tests for NotificationContentDAO * * Tests CRUD operations, query helpers, and data conversions */ class NotificationContentDAOTests: XCTestCase { var persistenceController: PersistenceController! var context: NSManagedObjectContext! override func setUp() { super.setUp() // Create in-memory Core Data stack persistenceController = PersistenceController(inMemory: true) context = persistenceController.viewContext XCTAssertNotNil(context, "Context should be available") } override func tearDown() { context = nil persistenceController = nil super.tearDown() } // MARK: - Create/Insert Tests func testCreate_WithAllParameters() { // Given: All parameters let scheduledTime = Date() let id = UUID().uuidString // When: Create entity let entity = NotificationContent.create( in: context, id: id, pluginVersion: "1.0.0", timesafariDid: "test-did", notificationType: "daily", title: "Test Title", body: "Test Body", scheduledTime: scheduledTime, timezone: "UTC", priority: 5, vibrationEnabled: true, soundEnabled: true, mediaUrl: "https://example.com/media.jpg", encryptedContent: "encrypted", encryptionKeyId: "key-1", ttlSeconds: 86400, deliveryStatus: "scheduled", deliveryAttempts: 0, metadata: "{\"key\":\"value\"}" ) // Then: Entity should be created with correct values XCTAssertNotNil(entity, "Entity should be created") XCTAssertEqual(entity.id, id) XCTAssertEqual(entity.pluginVersion, "1.0.0") XCTAssertEqual(entity.timesafariDid, "test-did") XCTAssertEqual(entity.notificationType, "daily") XCTAssertEqual(entity.title, "Test Title") XCTAssertEqual(entity.body, "Test Body") XCTAssertEqual(entity.scheduledTime, scheduledTime) XCTAssertEqual(entity.timezone, "UTC") XCTAssertEqual(entity.priority, 5) XCTAssertEqual(entity.vibrationEnabled, true) XCTAssertEqual(entity.soundEnabled, true) XCTAssertEqual(entity.mediaUrl, "https://example.com/media.jpg") XCTAssertEqual(entity.encryptedContent, "encrypted") XCTAssertEqual(entity.encryptionKeyId, "key-1") XCTAssertEqual(entity.ttlSeconds, 86400) XCTAssertEqual(entity.deliveryStatus, "scheduled") XCTAssertEqual(entity.deliveryAttempts, 0) XCTAssertNotNil(entity.createdAt) XCTAssertNotNil(entity.updatedAt) } func testCreate_WithMinimalParameters() { // Given: Minimal parameters (only required) let scheduledTime = Date() let id = UUID().uuidString // When: Create entity let entity = NotificationContent.create( in: context, id: id, scheduledTime: scheduledTime ) // Then: Entity should be created with defaults XCTAssertNotNil(entity, "Entity should be created") XCTAssertEqual(entity.id, id) XCTAssertEqual(entity.scheduledTime, scheduledTime) XCTAssertEqual(entity.priority, 0) // Default XCTAssertEqual(entity.vibrationEnabled, false) // Default XCTAssertEqual(entity.soundEnabled, true) // Default XCTAssertEqual(entity.ttlSeconds, 604800) // Default (7 days) XCTAssertNotNil(entity.createdAt) XCTAssertNotNil(entity.updatedAt) } func testCreate_FromDictionary_WithEpochMillis() { // Given: Dictionary with epoch milliseconds let scheduledTimeMillis: Int64 = 1609459200000 let createdAtMillis: Int64 = 1609459200000 let dict: [String: Any] = [ "id": "test-id", "title": "Test", "scheduledTime": scheduledTimeMillis, "createdAt": createdAtMillis, "priority": 5, "deliveryAttempts": 2 ] // When: Create from dictionary let entity = NotificationContent.create(in: context, from: dict) // Then: Entity should be created with converted dates XCTAssertNotNil(entity, "Entity should be created") XCTAssertEqual(entity.id, "test-id") XCTAssertEqual(entity.title, "Test") XCTAssertEqual(entity.priority, 5) XCTAssertEqual(entity.deliveryAttempts, 2) // Verify date conversion let expectedDate = DailyNotificationDataConversions.dateFromEpochMillis(scheduledTimeMillis) XCTAssertEqual(entity.scheduledTime, expectedDate) } func testCreate_FromDictionary_WithDate() { // Given: Dictionary with Date objects let scheduledTime = Date() let dict: [String: Any] = [ "id": "test-id", "scheduledTime": scheduledTime, "title": "Test" ] // When: Create from dictionary let entity = NotificationContent.create(in: context, from: dict) // Then: Entity should be created XCTAssertNotNil(entity, "Entity should be created") XCTAssertEqual(entity.id, "test-id") XCTAssertEqual(entity.scheduledTime, scheduledTime) } func testCreate_FromDictionary_MissingRequiredId() { // Given: Dictionary without required id let dict: [String: Any] = [ "title": "Test" ] // When: Create from dictionary let entity = NotificationContent.create(in: context, from: dict) // Then: Should be nil XCTAssertNil(entity, "Missing id should produce nil") } // MARK: - Read/Query Tests func testFetch_ById_Found() { // Given: Entity in database let id = UUID().uuidString let entity = NotificationContent.create( in: context, id: id, scheduledTime: Date() ) try! context.save() // When: Fetch by id let fetched = NotificationContent.fetch(by: id, in: context) // Then: Should find entity XCTAssertNotNil(fetched, "Should find entity") XCTAssertEqual(fetched?.id, id) } func testFetch_ById_NotFound() { // Given: No entity in database // (empty context) // When: Fetch by non-existent id let fetched = NotificationContent.fetch(by: "non-existent", in: context) // Then: Should be nil XCTAssertNil(fetched, "Should not find entity") } func testFetchAll_Empty() { // Given: Empty database // When: Fetch all let all = NotificationContent.fetchAll(in: context) // Then: Should be empty XCTAssertEqual(all.count, 0, "Should be empty") } func testFetchAll_WithEntities() { // Given: Multiple entities for i in 1...5 { NotificationContent.create( in: context, id: "id-\(i)", scheduledTime: Date() ) } try! context.save() // When: Fetch all let all = NotificationContent.fetchAll(in: context) // Then: Should find all XCTAssertEqual(all.count, 5, "Should find all entities") } func testQuery_ByTimesafariDid() { // Given: Entities with different timesafariDid NotificationContent.create( in: context, id: "id-1", timesafariDid: "did-1", scheduledTime: Date() ) NotificationContent.create( in: context, id: "id-2", timesafariDid: "did-1", scheduledTime: Date() ) NotificationContent.create( in: context, id: "id-3", timesafariDid: "did-2", scheduledTime: Date() ) try! context.save() // When: Query by timesafariDid let results = NotificationContent.query(by: "did-1", in: context) // Then: Should find only matching entities XCTAssertEqual(results.count, 2, "Should find 2 entities") XCTAssertTrue(results.allSatisfy { $0.timesafariDid == "did-1" }) } func testQuery_ByNotificationType() { // Given: Entities with different notification types NotificationContent.create( in: context, id: "id-1", notificationType: "daily", scheduledTime: Date() ) NotificationContent.create( in: context, id: "id-2", notificationType: "daily", scheduledTime: Date() ) NotificationContent.create( in: context, id: "id-3", notificationType: "weekly", scheduledTime: Date() ) try! context.save() // When: Query by notificationType let results = NotificationContent.query(by: "daily", in: context) // Then: Should find only matching entities XCTAssertEqual(results.count, 2, "Should find 2 entities") XCTAssertTrue(results.allSatisfy { $0.notificationType == "daily" }) } func testQuery_ScheduledTimeBetween() { // Given: Entities with different scheduled times let startDate = Date() let midDate = startDate.addingTimeInterval(3600) // 1 hour later let endDate = startDate.addingTimeInterval(7200) // 2 hours later let outsideDate = startDate.addingTimeInterval(10800) // 3 hours later NotificationContent.create(in: context, id: "id-1", scheduledTime: startDate) NotificationContent.create(in: context, id: "id-2", scheduledTime: midDate) NotificationContent.create(in: context, id: "id-3", scheduledTime: endDate) NotificationContent.create(in: context, id: "id-4", scheduledTime: outsideDate) try! context.save() // When: Query by scheduledTime range let results = NotificationContent.query( scheduledTimeBetween: startDate, and: endDate, in: context ) // Then: Should find entities in range XCTAssertEqual(results.count, 3, "Should find 3 entities in range") XCTAssertTrue(results.allSatisfy { $0.scheduledTime! >= startDate && $0.scheduledTime! <= endDate }) } func testQuery_ByDeliveryStatus() { // Given: Entities with different delivery statuses NotificationContent.create( in: context, id: "id-1", deliveryStatus: "scheduled", scheduledTime: Date() ) NotificationContent.create( in: context, id: "id-2", deliveryStatus: "scheduled", scheduledTime: Date() ) NotificationContent.create( in: context, id: "id-3", deliveryStatus: "delivered", scheduledTime: Date() ) try! context.save() // When: Query by deliveryStatus let results = NotificationContent.query(by: "scheduled", in: context) // Then: Should find only matching entities XCTAssertEqual(results.count, 2, "Should find 2 entities") XCTAssertTrue(results.allSatisfy { $0.deliveryStatus == "scheduled" }) } func testQueryReadyForDelivery() { // Given: Entities with different scheduled times let now = Date() let past = now.addingTimeInterval(-3600) // 1 hour ago let future = now.addingTimeInterval(3600) // 1 hour from now NotificationContent.create(in: context, id: "id-1", scheduledTime: past) NotificationContent.create(in: context, id: "id-2", scheduledTime: now) NotificationContent.create(in: context, id: "id-3", scheduledTime: future) try! context.save() // When: Query ready for delivery let results = NotificationContent.queryReadyForDelivery(currentTime: now, in: context) // Then: Should find only past/current entities XCTAssertEqual(results.count, 2, "Should find 2 ready entities") XCTAssertTrue(results.allSatisfy { $0.scheduledTime! <= now }) } // MARK: - Update Tests func testTouch_UpdatesUpdatedAt() { // Given: Entity with original updatedAt let entity = NotificationContent.create( in: context, id: "test-id", scheduledTime: Date() ) let originalUpdatedAt = entity.updatedAt try! context.save() // Wait a bit to ensure time difference Thread.sleep(forTimeInterval: 0.1) // When: Touch entity entity.touch() try! context.save() // Then: updatedAt should be newer XCTAssertNotNil(entity.updatedAt) XCTAssertGreaterThan(entity.updatedAt!, originalUpdatedAt!) } func testUpdateDeliveryStatus() { // Given: Entity with initial status let entity = NotificationContent.create( in: context, id: "test-id", deliveryStatus: "scheduled", deliveryAttempts: 0, scheduledTime: Date() ) try! context.save() // When: Update delivery status entity.updateDeliveryStatus("delivered") try! context.save() // Then: Status and attempts should be updated XCTAssertEqual(entity.deliveryStatus, "delivered") XCTAssertEqual(entity.deliveryAttempts, 1) XCTAssertNotNil(entity.lastDeliveryAttempt) } func testRecordUserInteraction() { // Given: Entity with no interactions let entity = NotificationContent.create( in: context, id: "test-id", userInteractionCount: 0, scheduledTime: Date() ) try! context.save() // When: Record user interaction entity.recordUserInteraction() try! context.save() // Then: Interaction count should increase XCTAssertEqual(entity.userInteractionCount, 1) XCTAssertNotNil(entity.lastUserInteraction) } // MARK: - Delete Tests func testDelete_ById_Found() { // Given: Entity in database let id = UUID().uuidString NotificationContent.create( in: context, id: id, scheduledTime: Date() ) try! context.save() // When: Delete by id let deleted = NotificationContent.delete(by: id, in: context) // Then: Should be deleted XCTAssertTrue(deleted, "Should delete entity") // Verify deleted let fetched = NotificationContent.fetch(by: id, in: context) XCTAssertNil(fetched, "Entity should be deleted") } func testDelete_ById_NotFound() { // Given: No entity in database // When: Delete by non-existent id let deleted = NotificationContent.delete(by: "non-existent", in: context) // Then: Should return false XCTAssertFalse(deleted, "Should return false for non-existent id") } func testDeleteAll() { // Given: Multiple entities for i in 1...5 { NotificationContent.create( in: context, id: "id-\(i)", scheduledTime: Date() ) } try! context.save() // When: Delete all let count = NotificationContent.deleteAll(in: context) // Then: Should delete all XCTAssertEqual(count, 5, "Should delete 5 entities") // Verify all deleted let all = NotificationContent.fetchAll(in: context) XCTAssertEqual(all.count, 0, "Should be empty") } }