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:
477
ios/Tests/NotificationDeliveryDAOTests.swift
Normal file
477
ios/Tests/NotificationDeliveryDAOTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user