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

470 lines
14 KiB
Swift

//
// NotificationConfigDAOTests.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 NotificationConfigDAO
*
* Tests CRUD operations and query helpers for configuration management
*/
class NotificationConfigDAOTests: 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 id = UUID().uuidString
// When: Create entity
let entity = NotificationConfig.create(
in: context,
id: id,
timesafariDid: "test-did",
configType: "notification",
configKey: "sound_enabled",
configValue: "true",
configDataType: "bool",
isEncrypted: false,
encryptionKeyId: nil,
ttlSeconds: 86400,
isActive: true,
metadata: "{\"key\":\"value\"}"
)
// Then: Entity should be created with correct values
XCTAssertNotNil(entity, "Entity should be created")
XCTAssertEqual(entity.id, id)
XCTAssertEqual(entity.timesafariDid, "test-did")
XCTAssertEqual(entity.configType, "notification")
XCTAssertEqual(entity.configKey, "sound_enabled")
XCTAssertEqual(entity.configValue, "true")
XCTAssertEqual(entity.configDataType, "bool")
XCTAssertEqual(entity.isEncrypted, false)
XCTAssertEqual(entity.ttlSeconds, 86400)
XCTAssertEqual(entity.isActive, true)
XCTAssertNotNil(entity.createdAt)
XCTAssertNotNil(entity.updatedAt)
}
func testCreate_WithMinimalParameters() {
// Given: Minimal parameters (only required id)
let id = UUID().uuidString
// When: Create entity
let entity = NotificationConfig.create(
in: context,
id: id
)
// Then: Entity should be created with defaults
XCTAssertNotNil(entity, "Entity should be created")
XCTAssertEqual(entity.id, id)
XCTAssertEqual(entity.isEncrypted, false) // Default
XCTAssertEqual(entity.ttlSeconds, 604800) // Default (7 days)
XCTAssertEqual(entity.isActive, true) // Default
XCTAssertNotNil(entity.createdAt)
XCTAssertNotNil(entity.updatedAt)
}
func testCreate_FromDictionary_WithEpochMillis() {
// Given: Dictionary with epoch milliseconds
let createdAtMillis: Int64 = 1609459200000
let dict: [String: Any] = [
"id": "test-id",
"configKey": "test_key",
"configValue": "test_value",
"createdAt": createdAtMillis,
"isActive": true
]
// When: Create from dictionary
let entity = NotificationConfig.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.configKey, "test_key")
XCTAssertEqual(entity.configValue, "test_value")
XCTAssertEqual(entity.isActive, true)
// Verify date conversion
let expectedDate = DailyNotificationDataConversions.dateFromEpochMillis(createdAtMillis)
XCTAssertEqual(entity.createdAt, expectedDate)
}
func testCreate_FromDictionary_MissingRequiredId() {
// Given: Dictionary without required id
let dict: [String: Any] = [
"configKey": "test_key"
]
// When: Create from dictionary
let entity = NotificationConfig.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 = NotificationConfig.create(
in: context,
id: id
)
try! context.save()
// When: Fetch by id
let fetched = NotificationConfig.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
// When: Fetch by non-existent id
let fetched = NotificationConfig.fetch(by: "non-existent", in: context)
// Then: Should be nil
XCTAssertNil(fetched, "Should not find entity")
}
func testFetch_ByConfigKey_Found() {
// Given: Entity with configKey
let configKey = "sound_enabled"
let entity = NotificationConfig.create(
in: context,
id: UUID().uuidString,
configKey: configKey
)
try! context.save()
// When: Fetch by configKey
let fetched = NotificationConfig.fetch(by: configKey, in: context)
// Then: Should find entity
XCTAssertNotNil(fetched, "Should find entity")
XCTAssertEqual(fetched?.configKey, configKey)
}
func testFetch_ByConfigKey_NotFound() {
// Given: No entity in database
// When: Fetch by non-existent configKey
let fetched = NotificationConfig.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 = NotificationConfig.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 {
NotificationConfig.create(
in: context,
id: "id-\(i)"
)
}
try! context.save()
// When: Fetch all
let all = NotificationConfig.fetchAll(in: context)
// Then: Should find all
XCTAssertEqual(all.count, 5, "Should find all entities")
}
func testQuery_ByTimesafariDid() {
// Given: Entities with different timesafariDid
NotificationConfig.create(
in: context,
id: "id-1",
timesafariDid: "did-1"
)
NotificationConfig.create(
in: context,
id: "id-2",
timesafariDid: "did-1"
)
NotificationConfig.create(
in: context,
id: "id-3",
timesafariDid: "did-2"
)
try! context.save()
// When: Query by timesafariDid
let results = NotificationConfig.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_ByConfigType() {
// Given: Entities with different config types
NotificationConfig.create(
in: context,
id: "id-1",
configType: "notification"
)
NotificationConfig.create(
in: context,
id: "id-2",
configType: "notification"
)
NotificationConfig.create(
in: context,
id: "id-3",
configType: "scheduling"
)
try! context.save()
// When: Query by configType
let results = NotificationConfig.query(by: "notification", in: context)
// Then: Should find only matching entities
XCTAssertEqual(results.count, 2, "Should find 2 entities")
XCTAssertTrue(results.allSatisfy { $0.configType == "notification" })
}
func testQueryActive() {
// Given: Entities with different active states
NotificationConfig.create(
in: context,
id: "id-1",
isActive: true
)
NotificationConfig.create(
in: context,
id: "id-2",
isActive: true
)
NotificationConfig.create(
in: context,
id: "id-3",
isActive: false
)
try! context.save()
// When: Query active
let results = NotificationConfig.queryActive(in: context)
// Then: Should find only active entities
XCTAssertEqual(results.count, 2, "Should find 2 active entities")
XCTAssertTrue(results.allSatisfy { $0.isActive == true })
}
func testQuery_ByConfigTypeAndIsActive() {
// Given: Entities with different types and active states
NotificationConfig.create(
in: context,
id: "id-1",
configType: "notification",
isActive: true
)
NotificationConfig.create(
in: context,
id: "id-2",
configType: "notification",
isActive: true
)
NotificationConfig.create(
in: context,
id: "id-3",
configType: "notification",
isActive: false
)
NotificationConfig.create(
in: context,
id: "id-4",
configType: "scheduling",
isActive: true
)
try! context.save()
// When: Query by configType and isActive
let results = NotificationConfig.query(
by: "notification",
isActive: true,
in: context
)
// Then: Should find only matching entities
XCTAssertEqual(results.count, 2, "Should find 2 entities")
XCTAssertTrue(results.allSatisfy {
$0.configType == "notification" && $0.isActive == true
})
}
// MARK: - Update Tests
func testUpdateValue() {
// Given: Entity with initial value
let entity = NotificationConfig.create(
in: context,
id: UUID().uuidString,
configValue: "old_value"
)
let originalUpdatedAt = entity.updatedAt
try! context.save()
// Wait a bit to ensure time difference
Thread.sleep(forTimeInterval: 0.1)
// When: Update value
entity.updateValue("new_value")
try! context.save()
// Then: Value and updatedAt should be updated
XCTAssertEqual(entity.configValue, "new_value")
XCTAssertNotNil(entity.updatedAt)
XCTAssertGreaterThan(entity.updatedAt!, originalUpdatedAt!)
}
func testSetActive() {
// Given: Entity with initial active state
let entity = NotificationConfig.create(
in: context,
id: UUID().uuidString,
isActive: true
)
try! context.save()
// When: Set inactive
entity.setActive(false)
try! context.save()
// Then: Active state should be updated
XCTAssertEqual(entity.isActive, false)
}
func testTouch_UpdatesUpdatedAt() {
// Given: Entity with original updatedAt
let entity = NotificationConfig.create(
in: context,
id: UUID().uuidString
)
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!)
}
// MARK: - Delete Tests
func testDelete_ById_Found() {
// Given: Entity in database
let id = UUID().uuidString
NotificationConfig.create(
in: context,
id: id
)
try! context.save()
// When: Delete by id
let deleted = NotificationConfig.delete(by: id, in: context)
// Then: Should be deleted
XCTAssertTrue(deleted, "Should delete entity")
// Verify deleted
let fetched = NotificationConfig.fetch(by: id, in: context)
XCTAssertNil(fetched, "Entity should be deleted")
}
func testDelete_ByConfigKey_Found() {
// Given: Entity with configKey
let configKey = "sound_enabled"
NotificationConfig.create(
in: context,
id: UUID().uuidString,
configKey: configKey
)
try! context.save()
// When: Delete by configKey
let deleted = NotificationConfig.delete(by: configKey, in: context)
// Then: Should be deleted
XCTAssertTrue(deleted, "Should delete entity")
// Verify deleted
let fetched = NotificationConfig.fetch(by: configKey, in: context)
XCTAssertNil(fetched, "Entity should be deleted")
}
func testDeleteAll() {
// Given: Multiple entities
for i in 1...5 {
NotificationConfig.create(
in: context,
id: "id-\(i)"
)
}
try! context.save()
// When: Delete all
let count = NotificationConfig.deleteAll(in: context)
// Then: Should delete all
XCTAssertEqual(count, 5, "Should delete 5 entities")
// Verify all deleted
let all = NotificationConfig.fetchAll(in: context)
XCTAssertEqual(all.count, 0, "Should be empty")
}
}