Files
daily-notification-plugin/ios/Plugin/DailyNotificationModel.swift
Matthew a90d08c425 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.
2025-12-09 02:23:05 -08:00

271 lines
8.1 KiB
Swift

//
// DailyNotificationModel.xcdatamodeld
// DailyNotificationPlugin
//
// Created by Matthew Raymer on 2025-09-22
// Copyright © 2025 TimeSafari. All rights reserved.
//
import Foundation
import CoreData
/**
* Core Data model for Daily Notification Plugin
* Mirrors Android SQLite schema for cross-platform consistency
*
* @author Matthew Raymer
* @version 1.1.0
* @created 2025-09-22 09:22:32 UTC
*/
// MARK: - ContentCache Entity
@objc(ContentCache)
public class ContentCache: NSManagedObject {
}
extension ContentCache {
@nonobjc public class func fetchRequest() -> NSFetchRequest<ContentCache> {
return NSFetchRequest<ContentCache>(entityName: "ContentCache")
}
@NSManaged public var id: String?
@NSManaged public var fetchedAt: Date?
@NSManaged public var ttlSeconds: Int32
@NSManaged public var payload: Data?
@NSManaged public var meta: String?
}
extension ContentCache: Identifiable {
}
// MARK: - Schedule Entity
@objc(Schedule)
public class Schedule: NSManagedObject {
}
extension Schedule {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Schedule> {
return NSFetchRequest<Schedule>(entityName: "Schedule")
}
@NSManaged public var id: String?
@NSManaged public var kind: String?
@NSManaged public var cron: String?
@NSManaged public var clockTime: String?
@NSManaged public var enabled: Bool
@NSManaged public var lastRunAt: Date?
@NSManaged public var nextRunAt: Date?
@NSManaged public var jitterMs: Int32
@NSManaged public var backoffPolicy: String?
@NSManaged public var stateJson: String?
}
extension Schedule: Identifiable {
}
// MARK: - Callback Entity
@objc(Callback)
public class Callback: NSManagedObject {
}
extension Callback {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Callback> {
return NSFetchRequest<Callback>(entityName: "Callback")
}
@NSManaged public var id: String?
@NSManaged public var kind: String?
@NSManaged public var target: String?
@NSManaged public var headersJson: String?
@NSManaged public var enabled: Bool
@NSManaged public var createdAt: Date?
}
extension Callback: Identifiable {
}
// MARK: - History Entity
@objc(History)
public class History: NSManagedObject {
}
extension History {
@nonobjc public class func fetchRequest() -> NSFetchRequest<History> {
return NSFetchRequest<History>(entityName: "History")
}
@NSManaged public var id: String?
@NSManaged public var refId: String?
@NSManaged public var kind: String?
@NSManaged public var occurredAt: Date?
@NSManaged public var durationMs: Int32
@NSManaged public var outcome: String?
@NSManaged public var diagJson: String?
}
extension History: Identifiable {
}
// MARK: - Persistence Controller
// Phase 2: CoreData integration for advanced features
// All entities now available: ContentCache, Schedule, Callback, History,
// NotificationContent, NotificationDelivery, NotificationConfig
class PersistenceController {
// Lazy initialization
private static var _shared: PersistenceController?
static var shared: PersistenceController {
if _shared == nil {
_shared = PersistenceController()
}
return _shared!
}
let container: NSPersistentContainer?
private var initializationError: Error?
init(inMemory: Bool = false) {
var tempContainer: NSPersistentContainer? = nil
do {
tempContainer = NSPersistentContainer(name: "DailyNotificationModel")
if inMemory {
tempContainer?.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
// Configure persistent store options
let description = tempContainer?.persistentStoreDescriptions.first
description?.shouldMigrateStoreAutomatically = true
description?.shouldInferMappingModelAutomatically = true
var loadError: Error? = nil
tempContainer?.loadPersistentStores { description, error in
if let error = error as NSError? {
loadError = error
print("DNP-PLUGIN: CoreData store load error: \(error.localizedDescription)")
print("DNP-PLUGIN: Error domain: \(error.domain), code: \(error.code)")
if let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String {
print("DNP-PLUGIN: Failure reason: \(failureReason)")
}
} else {
print("DNP-PLUGIN: CoreData store loaded successfully")
print("DNP-PLUGIN: Store URL: \(description.url?.absoluteString ?? "unknown")")
}
}
if let error = loadError {
self.initializationError = error
self.container = nil
} else {
// Configure view context
if let context = tempContainer?.viewContext {
context.automaticallyMergesChangesFromParent = true
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Verify all entities are available
verifyEntities(in: context)
}
self.container = tempContainer
}
} catch {
print("DNP-PLUGIN: Failed to initialize CoreData container: \(error.localizedDescription)")
self.initializationError = error
self.container = nil
}
}
/**
* Check if CoreData is available
*/
var isAvailable: Bool {
return container != nil && initializationError == nil
}
/**
* Get the main view context
*
* @return NSManagedObjectContext or nil if not available
*/
var viewContext: NSManagedObjectContext? {
return container?.viewContext
}
/**
* Create a new background context for async operations
*
* @return NSManagedObjectContext or nil if not available
*/
func newBackgroundContext() -> NSManagedObjectContext? {
return container?.newBackgroundContext()
}
/**
* Save the view context
*
* @return true if saved successfully, false otherwise
*/
func save() -> Bool {
guard let context = viewContext else {
print("DNP-PLUGIN: Cannot save - CoreData not available")
return false
}
if context.hasChanges {
do {
try context.save()
print("DNP-PLUGIN: CoreData context saved successfully")
return true
} catch {
print("DNP-PLUGIN: Error saving CoreData context: \(error.localizedDescription)")
context.rollback()
return false
}
}
return true
}
/**
* Verify all entities are available in the model
*
* @param context Managed object context
*/
private func verifyEntities(in context: NSManagedObjectContext) {
guard let model = context.persistentStoreCoordinator?.managedObjectModel else {
print("DNP-PLUGIN: Cannot verify entities - no managed object model")
return
}
let entityNames = [
"ContentCache",
"Schedule",
"Callback",
"History",
"NotificationContent",
"NotificationDelivery",
"NotificationConfig"
]
var missingEntities: [String] = []
for entityName in entityNames {
if model.entitiesByName[entityName] == nil {
missingEntities.append(entityName)
}
}
if missingEntities.isEmpty {
print("DNP-PLUGIN: All \(entityNames.count) entities verified in CoreData model")
} else {
print("DNP-PLUGIN: WARNING - Missing entities: \(missingEntities.joined(separator: ", "))")
}
}
}