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.
This commit is contained in:
Matthew
2025-12-09 02:23:05 -08:00
parent dd8d67462f
commit a90d08c425
14 changed files with 5201 additions and 9 deletions

View File

@@ -0,0 +1,477 @@
//
// 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")
}
}