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.
478 lines
16 KiB
Swift
478 lines
16 KiB
Swift
//
|
|
// NotificationDeliveryDAOTests.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 NotificationDeliveryDAO
|
|
*
|
|
* Tests CRUD operations, query helpers, relationships, and cascade delete
|
|
*/
|
|
class NotificationDeliveryDAOTests: 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 deliveryTimestamp = Date()
|
|
let id = UUID().uuidString
|
|
let notificationId = UUID().uuidString
|
|
|
|
// When: Create entity
|
|
let entity = NotificationDelivery.create(
|
|
in: context,
|
|
id: id,
|
|
notificationId: notificationId,
|
|
timesafariDid: "test-did",
|
|
deliveryTimestamp: deliveryTimestamp,
|
|
deliveryStatus: "delivered",
|
|
deliveryMethod: "local",
|
|
deliveryAttemptNumber: 1,
|
|
deliveryDurationMs: 100,
|
|
userInteractionType: "tap",
|
|
userInteractionTimestamp: deliveryTimestamp,
|
|
userInteractionDurationMs: 50,
|
|
errorCode: nil,
|
|
errorMessage: nil,
|
|
deviceInfo: "{\"model\":\"iPhone\"}",
|
|
networkInfo: "{\"type\":\"wifi\"}",
|
|
batteryLevel: 80,
|
|
dozeModeActive: false,
|
|
exactAlarmPermission: true,
|
|
notificationPermission: true,
|
|
metadata: "{\"key\":\"value\"}"
|
|
)
|
|
|
|
// Then: Entity should be created with correct values
|
|
XCTAssertNotNil(entity, "Entity should be created")
|
|
XCTAssertEqual(entity.id, id)
|
|
XCTAssertEqual(entity.notificationId, notificationId)
|
|
XCTAssertEqual(entity.timesafariDid, "test-did")
|
|
XCTAssertEqual(entity.deliveryTimestamp, deliveryTimestamp)
|
|
XCTAssertEqual(entity.deliveryStatus, "delivered")
|
|
XCTAssertEqual(entity.deliveryMethod, "local")
|
|
XCTAssertEqual(entity.deliveryAttemptNumber, 1)
|
|
XCTAssertEqual(entity.deliveryDurationMs, 100)
|
|
XCTAssertEqual(entity.userInteractionType, "tap")
|
|
XCTAssertEqual(entity.userInteractionTimestamp, deliveryTimestamp)
|
|
XCTAssertEqual(entity.userInteractionDurationMs, 50)
|
|
XCTAssertEqual(entity.batteryLevel, 80)
|
|
XCTAssertEqual(entity.dozeModeActive, false)
|
|
XCTAssertEqual(entity.exactAlarmPermission, true)
|
|
XCTAssertEqual(entity.notificationPermission, true)
|
|
}
|
|
|
|
func testCreate_WithRelationship() {
|
|
// Given: NotificationContent entity
|
|
let notificationId = UUID().uuidString
|
|
let notification = NotificationContent.create(
|
|
in: context,
|
|
id: notificationId,
|
|
scheduledTime: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Create delivery with relationship
|
|
let delivery = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId,
|
|
notificationContent: notification,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// Then: Relationship should be set
|
|
XCTAssertNotNil(delivery.notificationContent, "Relationship should be set")
|
|
XCTAssertEqual(delivery.notificationContent?.id, notificationId)
|
|
|
|
// Verify inverse relationship
|
|
XCTAssertTrue(notification.deliveries?.contains(delivery) ?? false,
|
|
"Inverse relationship should be set")
|
|
}
|
|
|
|
func testCreate_FromDictionary_WithEpochMillis() {
|
|
// Given: Dictionary with epoch milliseconds
|
|
let deliveryTimestampMillis: Int64 = 1609459200000
|
|
let dict: [String: Any] = [
|
|
"id": "test-id",
|
|
"notificationId": "notif-id",
|
|
"deliveryTimestamp": deliveryTimestampMillis,
|
|
"deliveryStatus": "delivered",
|
|
"deliveryAttemptNumber": 1,
|
|
"batteryLevel": 80
|
|
]
|
|
|
|
// When: Create from dictionary
|
|
let entity = NotificationDelivery.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.notificationId, "notif-id")
|
|
XCTAssertEqual(entity.deliveryStatus, "delivered")
|
|
XCTAssertEqual(entity.deliveryAttemptNumber, 1)
|
|
XCTAssertEqual(entity.batteryLevel, 80)
|
|
|
|
// Verify date conversion
|
|
let expectedDate = DailyNotificationDataConversions.dateFromEpochMillis(deliveryTimestampMillis)
|
|
XCTAssertEqual(entity.deliveryTimestamp, expectedDate)
|
|
}
|
|
|
|
// MARK: - Read/Query Tests
|
|
|
|
func testFetch_ById_Found() {
|
|
// Given: Entity in database
|
|
let id = UUID().uuidString
|
|
let entity = NotificationDelivery.create(
|
|
in: context,
|
|
id: id,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Fetch by id
|
|
let fetched = NotificationDelivery.fetch(by: id, in: context)
|
|
|
|
// Then: Should find entity
|
|
XCTAssertNotNil(fetched, "Should find entity")
|
|
XCTAssertEqual(fetched?.id, id)
|
|
}
|
|
|
|
func testQuery_ByNotificationId() {
|
|
// Given: Deliveries for different notifications
|
|
let notificationId1 = UUID().uuidString
|
|
let notificationId2 = UUID().uuidString
|
|
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId1,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId1,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId2,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Query by notificationId
|
|
let results = NotificationDelivery.query(by: notificationId1, in: context)
|
|
|
|
// Then: Should find only matching deliveries
|
|
XCTAssertEqual(results.count, 2, "Should find 2 deliveries")
|
|
XCTAssertTrue(results.allSatisfy { $0.notificationId == notificationId1 })
|
|
}
|
|
|
|
func testQuery_DeliveryTimestampBetween() {
|
|
// Given: Deliveries with different timestamps
|
|
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
|
|
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: startDate
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: midDate
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: endDate
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: outsideDate
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Query by deliveryTimestamp range
|
|
let results = NotificationDelivery.query(
|
|
deliveryTimestampBetween: startDate,
|
|
and: endDate,
|
|
in: context
|
|
)
|
|
|
|
// Then: Should find deliveries in range
|
|
XCTAssertEqual(results.count, 3, "Should find 3 deliveries in range")
|
|
XCTAssertTrue(results.allSatisfy {
|
|
$0.deliveryTimestamp! >= startDate && $0.deliveryTimestamp! <= endDate
|
|
})
|
|
}
|
|
|
|
func testQuery_ByDeliveryStatus() {
|
|
// Given: Deliveries with different statuses
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryStatus: "delivered",
|
|
deliveryTimestamp: Date()
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryStatus: "delivered",
|
|
deliveryTimestamp: Date()
|
|
)
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryStatus: "failed",
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Query by deliveryStatus
|
|
let results = NotificationDelivery.query(by: "delivered", in: context)
|
|
|
|
// Then: Should find only matching deliveries
|
|
XCTAssertEqual(results.count, 2, "Should find 2 deliveries")
|
|
XCTAssertTrue(results.allSatisfy { $0.deliveryStatus == "delivered" })
|
|
}
|
|
|
|
// MARK: - Relationship Tests
|
|
|
|
func testRelationship_OneToMany() {
|
|
// Given: NotificationContent with multiple deliveries
|
|
let notificationId = UUID().uuidString
|
|
let notification = NotificationContent.create(
|
|
in: context,
|
|
id: notificationId,
|
|
scheduledTime: Date()
|
|
)
|
|
|
|
let delivery1 = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId,
|
|
notificationContent: notification,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
let delivery2 = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId,
|
|
notificationContent: notification,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// Then: Notification should have multiple deliveries
|
|
let deliveries = notification.deliveries as? Set<NotificationDelivery>
|
|
XCTAssertNotNil(deliveries, "Deliveries should be available")
|
|
XCTAssertEqual(deliveries?.count, 2, "Should have 2 deliveries")
|
|
XCTAssertTrue(deliveries?.contains(delivery1) ?? false)
|
|
XCTAssertTrue(deliveries?.contains(delivery2) ?? false)
|
|
}
|
|
|
|
// MARK: - Cascade Delete Tests
|
|
|
|
func testCascadeDelete_WhenNotificationContentDeleted() {
|
|
// Given: NotificationContent with deliveries
|
|
let notificationId = UUID().uuidString
|
|
let notification = NotificationContent.create(
|
|
in: context,
|
|
id: notificationId,
|
|
scheduledTime: Date()
|
|
)
|
|
|
|
let delivery1 = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId,
|
|
notificationContent: notification,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
let delivery2 = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId,
|
|
notificationContent: notification,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// Verify deliveries exist
|
|
let deliveriesBefore = NotificationDelivery.query(by: notificationId, in: context)
|
|
XCTAssertEqual(deliveriesBefore.count, 2, "Should have 2 deliveries")
|
|
|
|
// When: Delete NotificationContent
|
|
NotificationContent.delete(by: notificationId, in: context)
|
|
|
|
// Then: Deliveries should be cascade deleted
|
|
let deliveriesAfter = NotificationDelivery.query(by: notificationId, in: context)
|
|
XCTAssertEqual(deliveriesAfter.count, 0, "Deliveries should be cascade deleted")
|
|
|
|
// Verify deliveries are actually deleted
|
|
XCTAssertNil(NotificationDelivery.fetch(by: delivery1.id!, in: context))
|
|
XCTAssertNil(NotificationDelivery.fetch(by: delivery2.id!, in: context))
|
|
}
|
|
|
|
// MARK: - Update Tests
|
|
|
|
func testUpdateDeliveryStatus() {
|
|
// Given: Entity with initial status
|
|
let entity = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryStatus: "pending",
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Update delivery status
|
|
entity.updateDeliveryStatus("delivered")
|
|
try! context.save()
|
|
|
|
// Then: Status should be updated
|
|
XCTAssertEqual(entity.deliveryStatus, "delivered")
|
|
}
|
|
|
|
func testRecordUserInteraction() {
|
|
// Given: Entity without interaction
|
|
let entity = NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Record user interaction
|
|
let interactionTime = Date()
|
|
entity.recordUserInteraction(
|
|
type: "tap",
|
|
timestamp: interactionTime,
|
|
durationMs: 100
|
|
)
|
|
try! context.save()
|
|
|
|
// Then: Interaction should be recorded
|
|
XCTAssertEqual(entity.userInteractionType, "tap")
|
|
XCTAssertEqual(entity.userInteractionTimestamp, interactionTime)
|
|
XCTAssertEqual(entity.userInteractionDurationMs, 100)
|
|
}
|
|
|
|
// MARK: - Delete Tests
|
|
|
|
func testDelete_ById_Found() {
|
|
// Given: Entity in database
|
|
let id = UUID().uuidString
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: id,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
try! context.save()
|
|
|
|
// When: Delete by id
|
|
let deleted = NotificationDelivery.delete(by: id, in: context)
|
|
|
|
// Then: Should be deleted
|
|
XCTAssertTrue(deleted, "Should delete entity")
|
|
|
|
// Verify deleted
|
|
let fetched = NotificationDelivery.fetch(by: id, in: context)
|
|
XCTAssertNil(fetched, "Entity should be deleted")
|
|
}
|
|
|
|
func testDeleteAll_ForNotificationId() {
|
|
// Given: Multiple deliveries for a notification
|
|
let notificationId = UUID().uuidString
|
|
for i in 1...3 {
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: notificationId,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
}
|
|
try! context.save()
|
|
|
|
// When: Delete all for notification
|
|
let count = NotificationDelivery.deleteAll(for: notificationId, in: context)
|
|
|
|
// Then: Should delete all
|
|
XCTAssertEqual(count, 3, "Should delete 3 deliveries")
|
|
|
|
// Verify all deleted
|
|
let remaining = NotificationDelivery.query(by: notificationId, in: context)
|
|
XCTAssertEqual(remaining.count, 0, "Should be empty")
|
|
}
|
|
|
|
func testDeleteAll() {
|
|
// Given: Multiple deliveries
|
|
for i in 1...5 {
|
|
NotificationDelivery.create(
|
|
in: context,
|
|
id: UUID().uuidString,
|
|
notificationId: UUID().uuidString,
|
|
deliveryTimestamp: Date()
|
|
)
|
|
}
|
|
try! context.save()
|
|
|
|
// When: Delete all
|
|
let count = NotificationDelivery.deleteAll(in: context)
|
|
|
|
// Then: Should delete all
|
|
XCTAssertEqual(count, 5, "Should delete 5 deliveries")
|
|
|
|
// Verify all deleted
|
|
let all = NotificationDelivery.fetchAll(in: context)
|
|
XCTAssertEqual(all.count, 0, "Should be empty")
|
|
}
|
|
}
|
|
|