import HealthKit import SQLite3 import XCTest @testable import HealthProbe final class SQLiteHealthArchiveStoreTests: XCTestCase { private var temporaryDirectory: URL! override func setUpWithError() throws { temporaryDirectory = FileManager.default.temporaryDirectory .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) } override func tearDownWithError() throws { if let temporaryDirectory { try? FileManager.default.removeItem(at: temporaryDirectory) } temporaryDirectory = nil } func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws { let store = SQLiteHealthArchiveStore(databaseURL: databaseURL()) let report = try await store.checkIntegrity() let records = try await store.records(for: HealthArchiveRecordRequest( sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue )) XCTAssertTrue(report.passed) XCTAssertEqual(report.schemaVersion, 2) XCTAssertEqual(report.sqliteIntegrityStatus, "ok") XCTAssertEqual(report.foreignKeyIssueCount, 0) XCTAssertTrue(report.missingTableNames.isEmpty) XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges")) XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates")) XCTAssertTrue(records.isEmpty) } func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws { let url = databaseURL() try createPrototypeDatabase(at: url) let store = SQLiteHealthArchiveStore(databaseURL: url) let report = try await store.checkIntegrity() XCTAssertTrue(report.passed) XCTAssertEqual(report.schemaVersion, 2) XCTAssertTrue(report.missingTableNames.isEmpty) } func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws { let url = databaseURL() let store = SQLiteHealthArchiveStore(databaseURL: url) let sample = makeStepCountSample() let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000)) let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060)) let records = try await store.records(for: HealthArchiveRecordRequest( sampleTypeIdentifier: sample.sampleType.identifier )) let report = try await store.checkIntegrity() let versionDebugRows = try sampleVersionDebugRows(at: url) XCTAssertEqual(firstWrite.insertedCount, 1) XCTAssertEqual(firstWrite.updatedCount, 0) XCTAssertEqual(firstWrite.unchangedCount, 0) XCTAssertEqual(try countRows(in: "samples", at: url), 1) XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows) XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1) XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1) XCTAssertEqual(secondWrite.insertedCount, 0) XCTAssertEqual(secondWrite.updatedCount, 0) XCTAssertEqual(secondWrite.unchangedCount, 1) XCTAssertEqual(records.count, 1) XCTAssertEqual(records.first?.displayValue, "42.0 count") XCTAssertTrue(report.passed) } func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws { let url = databaseURL() let store = SQLiteHealthArchiveStore(databaseURL: url) let firstSample = makeStepCountSample(value: 42, start: 1_000) let secondSample = makeStepCountSample(value: 7, start: 2_000) let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000)) _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060)) try await store.recordDisappearance( sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120) ) let observationIDs = try observationIDs(at: url) XCTAssertEqual(observationIDs.count, 3) let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest( fromObservationID: observationIDs[0], toObservationID: observationIDs[1], sampleTypeIdentifier: typeIdentifier )) let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest( fromObservationID: observationIDs[0], toObservationID: observationIDs[1], sampleTypeIdentifier: typeIdentifier, kind: .appeared, limit: 10 )) let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest( fromObservationID: observationIDs[1], toObservationID: observationIDs[2], sampleTypeIdentifier: typeIdentifier )) let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest( fromObservationID: observationIDs[1], toObservationID: observationIDs[2], sampleTypeIdentifier: typeIdentifier, kind: .disappeared, limit: 10 )) XCTAssertEqual(appearedSummary.appearedCount, 1) XCTAssertEqual(appearedSummary.disappearedCount, 0) XCTAssertEqual(appearedSummary.representationChangedCount, 0) XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"]) XCTAssertEqual(disappearedSummary.appearedCount, 0) XCTAssertEqual(disappearedSummary.disappearedCount, 1) XCTAssertEqual(disappearedSummary.representationChangedCount, 0) XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"]) XCTAssertNotNil(disappearedRecords.first?.disappearedAt) } func testAggregateComparisonUsesMaterializedDailyAggregates() async throws { let url = databaseURL() let store = SQLiteHealthArchiveStore(databaseURL: url) let firstSample = makeStepCountSample(value: 42, start: 1_000) let secondSample = makeStepCountSample(value: 7, start: 2_000) let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000)) _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060)) let observationIDs = try observationIDs(at: url) XCTAssertEqual(observationIDs.count, 2) let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest( fromObservationID: observationIDs[0], toObservationID: observationIDs[1], sampleTypeIdentifier: typeIdentifier, limit: 10 )) XCTAssertEqual(rows.count, 1) XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier) XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1) XCTAssertEqual(rows.first?.toVisibleRecordCount, 2) XCTAssertEqual(rows.first?.visibleRecordDelta, 1) XCTAssertEqual(rows.first?.fromValueSum, 42) XCTAssertEqual(rows.first?.toValueSum, 49) XCTAssertEqual(rows.first?.valueSumDelta, 7) } private func databaseURL() -> URL { temporaryDirectory.appending(path: "Archive.sqlite") } private func createPrototypeDatabase(at url: URL) throws { var db: OpaquePointer? guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else { sqlite3_close(db) XCTFail("Could not create prototype database") return } defer { sqlite3_close(db) } let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil) XCTAssertEqual(status, SQLITE_OK) } private func makeStepCountSample() -> HKQuantitySample { makeStepCountSample(value: 42, start: 1_000) } private func makeStepCountSample(value: Double, start: TimeInterval) -> HKQuantitySample { let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)! let quantity = HKQuantity(unit: .count(), doubleValue: value) let startDate = Date(timeIntervalSince1970: start) let endDate = Date(timeIntervalSince1970: start + 300) return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate) } private func countRows(in tableName: String, at url: URL) throws -> Int { var db: OpaquePointer? guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else { sqlite3_close(db) XCTFail("Could not open test database") return 0 } defer { sqlite3_close(db) } var statement: OpaquePointer? guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else { sqlite3_finalize(statement) XCTFail("Could not prepare count query") return 0 } defer { sqlite3_finalize(statement) } guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } return Int(sqlite3_column_int(statement, 0)) } private func observationIDs(at url: URL) throws -> [Int64] { var db: OpaquePointer? guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else { sqlite3_close(db) XCTFail("Could not open test database") return [] } defer { sqlite3_close(db) } var statement: OpaquePointer? guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else { sqlite3_finalize(statement) XCTFail("Could not prepare observation query") return [] } defer { sqlite3_finalize(statement) } var ids: [Int64] = [] while sqlite3_step(statement) == SQLITE_ROW { ids.append(sqlite3_column_int64(statement, 0)) } return ids } private func sampleVersionDebugRows(at url: URL) throws -> String { var db: OpaquePointer? guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else { sqlite3_close(db) return "could not open database" } defer { sqlite3_close(db) } let sql = """ SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit, v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version, sr.operating_system_version, v.hk_device_id, v.metadata_id FROM sample_versions v LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id LEFT JOIN sources src ON src.id = sr.source_id ORDER BY v.id """ var statement: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { sqlite3_finalize(statement) return "could not prepare version debug query" } defer { sqlite3_finalize(statement) } var rows: [String] = [] while sqlite3_step(statement) == SQLITE_ROW { rows.append((0..<13).map { index in if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL { return "null" } if let text = sqlite3_column_text(statement, Int32(index)) { return String(cString: text) } return "\(sqlite3_column_double(statement, Int32(index)))" }.joined(separator: "|")) } return rows.joined(separator: "\n") } }