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.
490 lines
16 KiB
Swift
490 lines
16 KiB
Swift
//
|
|
// 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")
|
|
}
|
|
}
|
|
|