Add debug bypass subscription toggle, tests, and data layer improvements

- Add runtime toggle in Settings (DEBUG only) to bypass subscription/hide trial banner
- IAPManager.bypassSubscription is now a @Published var persisted via UserDefaults
- Hide upgrade banner in SettingsTabView and trial warnings when bypass is enabled
- Add FeelsTests directory with integration tests
- Update DataController, DataControllerGET, DataControllerUPDATE
- Update Xcode project and scheme configuration
- Update localization strings and App Store screen docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-15 17:12:56 -06:00
parent 7c142568be
commit 7639f881da
14 changed files with 1064 additions and 34 deletions

View File

@@ -0,0 +1,366 @@
//
// DataControllerCRUDTests.swift
// FeelsTests
//
// Tests for DataController create, read, update, and delete operations.
//
import XCTest
import SwiftData
@testable import Feels
@MainActor
final class DataControllerCreateTests: XCTestCase {
var sut: DataController!
override func setUp() {
super.setUp()
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
}
override func tearDown() {
sut = nil
super.tearDown()
}
// MARK: - Phase 2: Create Tests
func test_add_insertsEntry() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .great, forDate: date, entryType: .listView)
let entry = sut.getEntry(byDate: date)
XCTAssertNotNil(entry, "Entry should exist after add")
XCTAssertEqual(entry?.mood, .great)
XCTAssertEqual(entry?.entryType, EntryType.listView.rawValue)
}
func test_add_replacesExistingForSameDate() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .great, forDate: date, entryType: .listView)
sut.add(mood: .bad, forDate: date, entryType: .widget)
let allEntries = sut.getAllEntries(byDate: date)
XCTAssertEqual(allEntries.count, 1, "Should have exactly 1 entry after 2 adds for same date")
XCTAssertEqual(allEntries.first?.mood, .bad, "Should keep the latest mood")
}
func test_add_setsCorrectWeekday() {
// June 15, 2024 is a Saturday (weekday = 7 in Gregorian)
let date = makeDate(2024, 6, 15)
sut.add(mood: .average, forDate: date, entryType: .listView)
let entry = sut.getEntry(byDate: date)
let expectedWeekday = Calendar.current.component(.weekday, from: date)
XCTAssertEqual(entry?.weekDay, expectedWeekday)
}
func test_add_allMoodValues() {
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
for (i, mood) in moods.enumerated() {
let date = makeDate(2024, 6, 10 + i)
sut.add(mood: mood, forDate: date, entryType: .listView)
let entry = sut.getEntry(byDate: date)
XCTAssertEqual(entry?.mood, mood, "Mood \(mood) should persist and read back correctly")
}
}
func test_addBatch_insertsMultiple() {
let entries: [(mood: Mood, date: Date, entryType: EntryType)] = [
(.great, makeDate(2024, 6, 10), .listView),
(.good, makeDate(2024, 6, 11), .widget),
(.average, makeDate(2024, 6, 12), .watch),
]
sut.addBatch(entries: entries)
for (mood, date, _) in entries {
let entry = sut.getEntry(byDate: date)
XCTAssertNotNil(entry, "Entry for \(date) should exist")
XCTAssertEqual(entry?.mood, mood)
}
}
func test_addBatch_replacesExisting() {
let date = makeDate(2024, 6, 10)
sut.add(mood: .horrible, forDate: date, entryType: .listView)
let batchEntries: [(mood: Mood, date: Date, entryType: EntryType)] = [
(.great, date, .widget),
]
sut.addBatch(entries: batchEntries)
let allEntries = sut.getAllEntries(byDate: date)
XCTAssertEqual(allEntries.count, 1)
XCTAssertEqual(allEntries.first?.mood, .great)
}
}
// MARK: - Phase 3: Read Tests
@MainActor
final class DataControllerReadTests: XCTestCase {
var sut: DataController!
override func setUp() {
super.setUp()
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
}
override func tearDown() {
sut = nil
super.tearDown()
}
func test_getEntry_emptyDB_returnsNil() {
let entry = sut.getEntry(byDate: Date())
XCTAssertNil(entry)
}
func test_getEntry_boundaryExclusion() {
// Insert entry at exactly midnight of Jan 2 (start of Jan 2)
// Use hour=0 to place it precisely at the boundary
let jan2Midnight = makeDate(2024, 1, 2, hour: 0)
let entry = MoodEntryModel(forDate: jan2Midnight, mood: .great, entryType: .listView)
sut.modelContext.insert(entry)
sut.save()
// Query for Jan 1 should NOT find the Jan 2 entry
// getEntry builds range [Jan 1 00:00, Jan 2 00:00] with <= the midnight entry leaks in
let jan1 = makeDate(2024, 1, 1)
let result = sut.getEntry(byDate: jan1)
XCTAssertNil(result, "Entry at midnight Jan 2 should NOT be returned when querying Jan 1")
}
func test_getData_filtersWeekdays() {
// Add entries for a full week (Mon-Sun)
// Jan 1, 2024 is a Monday (weekday=2)
for i in 0..<7 {
let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))!
sut.add(mood: .average, forDate: date, entryType: .listView)
}
// Filter to only weekdays 2 (Mon) and 6 (Fri)
let startDate = makeDate(2024, 1, 1)
let endDate = makeDate(2024, 1, 7)
let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [2, 6])
XCTAssertEqual(results.count, 2, "Should return only Monday and Friday")
}
func test_getData_emptyIncludedDays_returnsAll() {
for i in 0..<7 {
let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))!
sut.add(mood: .average, forDate: date, entryType: .listView)
}
let startDate = makeDate(2024, 1, 1)
let endDate = makeDate(2024, 1, 7)
let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [])
XCTAssertEqual(results.count, 7, "Empty includedDays should return all 7 entries")
}
func test_splitIntoYearMonth_groupsCorrectly() {
// Add entries in different months
sut.add(mood: .great, forDate: makeDate(2024, 1, 15), entryType: .listView)
sut.add(mood: .good, forDate: makeDate(2024, 1, 20), entryType: .listView)
sut.add(mood: .bad, forDate: makeDate(2024, 3, 5), entryType: .listView)
let grouped = sut.splitIntoYearMonth(includedDays: [])
XCTAssertEqual(grouped[2024]?[1]?.count, 2, "Jan 2024 should have 2 entries")
XCTAssertEqual(grouped[2024]?[3]?.count, 1, "Mar 2024 should have 1 entry")
XCTAssertNil(grouped[2024]?[2], "Feb 2024 should have no entries")
}
func test_earliestEntry() {
sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView)
sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView)
sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView)
let earliest = sut.earliestEntry
XCTAssertNotNil(earliest)
XCTAssertTrue(Calendar.current.isDate(earliest!.forDate, inSameDayAs: makeDate(2024, 1, 1)))
}
func test_latestEntry() {
sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView)
sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView)
sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView)
let latest = sut.latestEntry
XCTAssertNotNil(latest)
XCTAssertTrue(Calendar.current.isDate(latest!.forDate, inSameDayAs: makeDate(2024, 6, 15)))
}
}
// MARK: - Phase 4: Update Tests
@MainActor
final class DataControllerUpdateTests: XCTestCase {
var sut: DataController!
override func setUp() {
super.setUp()
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
}
override func tearDown() {
sut = nil
super.tearDown()
}
func test_update_changesMood() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .bad, forDate: date, entryType: .listView)
let result = sut.update(entryDate: date, withMood: .great)
XCTAssertTrue(result)
let entry = sut.getEntry(byDate: date)
XCTAssertEqual(entry?.mood, .great)
}
func test_update_nonexistent_returnsFalse() {
let result = sut.update(entryDate: makeDate(2024, 6, 15), withMood: .great)
XCTAssertFalse(result)
}
func test_updateNotes_setsNotes() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .good, forDate: date, entryType: .listView)
let result = sut.updateNotes(forDate: date, notes: "Had a great day")
XCTAssertTrue(result)
let entry = sut.getEntry(byDate: date)
XCTAssertEqual(entry?.notes, "Had a great day")
}
func test_updateNotes_nilClearsNotes() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .good, forDate: date, entryType: .listView)
sut.updateNotes(forDate: date, notes: "Some notes")
let result = sut.updateNotes(forDate: date, notes: nil)
XCTAssertTrue(result)
let entry = sut.getEntry(byDate: date)
XCTAssertNil(entry?.notes)
}
func test_updatePhoto_setsPhotoID() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .good, forDate: date, entryType: .listView)
let photoID = UUID()
let result = sut.updatePhoto(forDate: date, photoID: photoID)
XCTAssertTrue(result)
let entry = sut.getEntry(byDate: date)
XCTAssertEqual(entry?.photoID, photoID)
}
func test_update_refreshesTimestamp() {
let date = makeDate(2024, 6, 15)
sut.add(mood: .bad, forDate: date, entryType: .listView)
let originalTimestamp = sut.getEntry(byDate: date)!.timestamp
// Small delay so timestamp would differ
let result = sut.update(entryDate: date, withMood: .great)
XCTAssertTrue(result)
let updatedEntry = sut.getEntry(byDate: date)!
XCTAssertGreaterThanOrEqual(updatedEntry.timestamp, originalTimestamp,
"Timestamp should be updated on mood change so removeDuplicates picks the right entry")
}
}
// MARK: - Phase 5: Delete Tests
@MainActor
final class DataControllerDeleteTests: XCTestCase {
var sut: DataController!
override func setUp() {
super.setUp()
let schema = Schema([MoodEntryModel.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
}
override func tearDown() {
sut = nil
super.tearDown()
}
func test_clearDB_removesAll() {
sut.add(mood: .great, forDate: makeDate(2024, 6, 10), entryType: .listView)
sut.add(mood: .bad, forDate: makeDate(2024, 6, 11), entryType: .listView)
sut.add(mood: .good, forDate: makeDate(2024, 6, 12), entryType: .listView)
sut.clearDB()
let all = sut.getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [])
XCTAssertTrue(all.isEmpty, "clearDB should remove all entries")
}
func test_deleteAllEntries_removesOnlyTargetDate() {
let date1 = makeDate(2024, 6, 10)
let date2 = makeDate(2024, 6, 11)
sut.add(mood: .great, forDate: date1, entryType: .listView)
sut.add(mood: .bad, forDate: date2, entryType: .listView)
sut.deleteAllEntries(forDate: date1)
XCTAssertNil(sut.getEntry(byDate: date1), "Deleted date should be gone")
XCTAssertNotNil(sut.getEntry(byDate: date2), "Other dates should be untouched")
}
func test_removeDuplicates_keepsBestEntry() {
let date = makeDate(2024, 6, 15)
// Manually insert 3 entries for the same date to simulate CloudKit conflict
let entry1 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing)
entry1.timestamp = makeDate(2024, 6, 15, hour: 8)
sut.modelContext.insert(entry1)
let entry2 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
entry2.timestamp = makeDate(2024, 6, 15, hour: 10)
sut.modelContext.insert(entry2)
let entry3 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView)
entry3.timestamp = makeDate(2024, 6, 15, hour: 9)
sut.modelContext.insert(entry3)
sut.save()
let removed = sut.removeDuplicates()
XCTAssertEqual(removed, 2, "Should remove 2 duplicates")
let remaining = sut.getAllEntries(byDate: date)
XCTAssertEqual(remaining.count, 1, "Should keep exactly 1 entry")
// Should keep non-missing with most recent timestamp (entry2, .great at 10am)
XCTAssertEqual(remaining.first?.mood, .great, "Should keep the best entry (non-missing, most recent)")
}
}
// MARK: - Test Helpers
func makeDate(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date {
var components = DateComponents()
components.year = year
components.month = month
components.day = day
components.hour = hour
components.minute = 0
components.second = 0
return Calendar.current.date(from: components)!
}