Files
daily-notification-plugin/ios/Tests/NotificationContentDAOTests.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

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")
}
}