HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
362 lines | 17.321kb
Bogdan Timofte authored 2 weeks ago
1
import HealthKit
2
import SQLite3
3
import XCTest
4
@testable import HealthProbe
5

            
6
final class SQLiteHealthArchiveStoreTests: XCTestCase {
7
    private var temporaryDirectory: URL!
8

            
9
    override func setUpWithError() throws {
10
        temporaryDirectory = FileManager.default.temporaryDirectory
11
            .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory)
12
        try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
13
    }
14

            
15
    override func tearDownWithError() throws {
16
        if let temporaryDirectory {
17
            try? FileManager.default.removeItem(at: temporaryDirectory)
18
        }
19
        temporaryDirectory = nil
20
    }
21

            
22
    func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
23
        let store = SQLiteHealthArchiveStore(databaseURL: databaseURL())
24

            
25
        let report = try await store.checkIntegrity()
26
        let records = try await store.records(for: HealthArchiveRecordRequest(
27
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
28
        ))
29

            
30
        XCTAssertTrue(report.passed)
31
        XCTAssertEqual(report.schemaVersion, 2)
32
        XCTAssertEqual(report.sqliteIntegrityStatus, "ok")
33
        XCTAssertEqual(report.foreignKeyIssueCount, 0)
34
        XCTAssertTrue(report.missingTableNames.isEmpty)
35
        XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
36
        XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
37
        XCTAssertTrue(records.isEmpty)
38
    }
39

            
40
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
41
        let url = databaseURL()
42
        try createPrototypeDatabase(at: url)
43
        let store = SQLiteHealthArchiveStore(databaseURL: url)
44

            
45
        let report = try await store.checkIntegrity()
46

            
47
        XCTAssertTrue(report.passed)
48
        XCTAssertEqual(report.schemaVersion, 2)
49
        XCTAssertTrue(report.missingTableNames.isEmpty)
50
    }
51

            
52
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
53
        let url = databaseURL()
54
        let store = SQLiteHealthArchiveStore(databaseURL: url)
55
        let sample = makeStepCountSample()
56

            
57
        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
58
        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
59
        let records = try await store.records(for: HealthArchiveRecordRequest(
60
            sampleTypeIdentifier: sample.sampleType.identifier
61
        ))
62
        let report = try await store.checkIntegrity()
63
        let versionDebugRows = try sampleVersionDebugRows(at: url)
64

            
65
        XCTAssertEqual(firstWrite.insertedCount, 1)
66
        XCTAssertEqual(firstWrite.updatedCount, 0)
67
        XCTAssertEqual(firstWrite.unchangedCount, 0)
68
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
69
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
70
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1)
71
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
72
        XCTAssertEqual(secondWrite.insertedCount, 0)
73
        XCTAssertEqual(secondWrite.updatedCount, 0)
74
        XCTAssertEqual(secondWrite.unchangedCount, 1)
75
        XCTAssertEqual(records.count, 1)
76
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
77
        XCTAssertTrue(report.passed)
78
    }
79

            
Bogdan Timofte authored 2 weeks ago
80
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
81
        let url = databaseURL()
82
        let store = SQLiteHealthArchiveStore(databaseURL: url)
83
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
84
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
85
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
86

            
87
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
88
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
89
        try await store.recordDisappearance(
90
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
91
            sampleTypeIdentifier: typeIdentifier,
92
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
93
        )
94
        let observationIDs = try observationIDs(at: url)
95
        XCTAssertEqual(observationIDs.count, 3)
96

            
97
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
98
            fromObservationID: observationIDs[0],
99
            toObservationID: observationIDs[1],
100
            sampleTypeIdentifier: typeIdentifier
101
        ))
102
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
103
            fromObservationID: observationIDs[0],
104
            toObservationID: observationIDs[1],
105
            sampleTypeIdentifier: typeIdentifier,
106
            kind: .appeared,
107
            limit: 10
108
        ))
109
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
110
            fromObservationID: observationIDs[1],
111
            toObservationID: observationIDs[2],
112
            sampleTypeIdentifier: typeIdentifier
113
        ))
114
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
115
            fromObservationID: observationIDs[1],
116
            toObservationID: observationIDs[2],
117
            sampleTypeIdentifier: typeIdentifier,
118
            kind: .disappeared,
119
            limit: 10
120
        ))
121

            
122
        XCTAssertEqual(appearedSummary.appearedCount, 1)
123
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
124
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
125
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
126
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
127
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
128
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
129
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
130
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
131
    }
132

            
Bogdan Timofte authored 2 weeks ago
133
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
134
        let url = databaseURL()
135
        let store = SQLiteHealthArchiveStore(databaseURL: url)
136
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
137
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
138
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
139

            
140
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
141
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
142
        let observationIDs = try observationIDs(at: url)
143
        XCTAssertEqual(observationIDs.count, 2)
144

            
145
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
146
            fromObservationID: observationIDs[0],
147
            toObservationID: observationIDs[1],
148
            sampleTypeIdentifier: typeIdentifier,
149
            limit: 10
150
        ))
151

            
152
        XCTAssertEqual(rows.count, 1)
153
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
154
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
155
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
156
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
157
        XCTAssertEqual(rows.first?.fromValueSum, 42)
158
        XCTAssertEqual(rows.first?.toValueSum, 49)
159
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
160
    }
161

            
Bogdan Timofte authored 2 weeks ago
162
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
163
        let url = databaseURL()
164
        let store = SQLiteHealthArchiveStore(databaseURL: url)
165
        let sample = makeStepCountSample(value: 42, start: 1_000)
166
        let typeIdentifier = sample.sampleType.identifier
167

            
168
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 3_000))
169

            
170
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
171
            visibleAtObservationID: nil,
172
            sampleTypeIdentifier: typeIdentifier,
173
            limit: 10
174
        ))
175

            
176
        XCTAssertEqual(rows.count, 1)
177
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
178
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
179
        XCTAssertEqual(rows.first?.valueSum, 42)
180
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
181
    }
182

            
183
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
184
        let url = databaseURL()
185
        let store = SQLiteHealthArchiveStore(databaseURL: url)
186
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
187

            
188
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
189
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
190
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
191

            
192
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
193
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
194
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
195
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
196

            
197
        let observationIDs = try observationIDs(at: url)
198
        XCTAssertEqual(observationIDs.count, 4)
199

            
200
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
201
            fromObservationID: observationIDs[0],
202
            toObservationID: observationIDs[observationIDs.count - 1],
203
            sampleTypeIdentifier: typeIdentifier
204
        ))
205

            
206
        XCTAssertEqual(rows.count, 1)
207
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
208
        XCTAssertEqual(rows.first?.disappearedCount, 2)
209
        XCTAssertEqual(rows.first?.appearedCount, 1)
210
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
211
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
212
        XCTAssertEqual(rows.first?.fromValueSum, 30)
213
        XCTAssertEqual(rows.first?.toValueSum, 30)
214
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
215
        XCTAssertTrue(rows.first?.sourceCompatible == true)
216
    }
217

            
218
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
219
        let url = databaseURL()
220
        let store = SQLiteHealthArchiveStore(databaseURL: url)
221
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
222

            
223
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
224
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
225
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
226

            
227
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
228
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
229
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
230
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
231

            
232
        let observationIDs = try observationIDs(at: url)
233
        XCTAssertEqual(observationIDs.count, 4)
234

            
235
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
236
            fromObservationID: observationIDs[0],
237
            toObservationID: observationIDs[observationIDs.count - 1],
238
            sampleTypeIdentifier: typeIdentifier
239
        ))
240

            
241
        XCTAssertEqual(rows.count, 1)
242
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
243
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
244
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
245
        XCTAssertEqual(rows.first?.fromValueSum, 30)
246
        XCTAssertEqual(rows.first?.toValueSum, 30)
247
    }
248

            
Bogdan Timofte authored 2 weeks ago
249
    private func databaseURL() -> URL {
250
        temporaryDirectory.appending(path: "Archive.sqlite")
251
    }
252

            
253
    private func createPrototypeDatabase(at url: URL) throws {
254
        var db: OpaquePointer?
255
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
256
            sqlite3_close(db)
257
            XCTFail("Could not create prototype database")
258
            return
259
        }
260
        defer { sqlite3_close(db) }
261
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
262
        XCTAssertEqual(status, SQLITE_OK)
263
    }
264

            
265
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
266
        makeStepCountSample(value: 42, start: 1_000)
267
    }
268

            
Bogdan Timofte authored 2 weeks ago
269
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
270
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
271
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
272
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
273
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
274
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
275
    }
276

            
277
    private func countRows(in tableName: String, at url: URL) throws -> Int {
278
        var db: OpaquePointer?
279
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
280
            sqlite3_close(db)
281
            XCTFail("Could not open test database")
282
            return 0
283
        }
284
        defer { sqlite3_close(db) }
285

            
286
        var statement: OpaquePointer?
287
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
288
            sqlite3_finalize(statement)
289
            XCTFail("Could not prepare count query")
290
            return 0
291
        }
292
        defer { sqlite3_finalize(statement) }
293

            
294
        guard sqlite3_step(statement) == SQLITE_ROW else {
295
            return 0
296
        }
297
        return Int(sqlite3_column_int(statement, 0))
298
    }
299

            
Bogdan Timofte authored 2 weeks ago
300
    private func observationIDs(at url: URL) throws -> [Int64] {
301
        var db: OpaquePointer?
302
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
303
            sqlite3_close(db)
304
            XCTFail("Could not open test database")
305
            return []
306
        }
307
        defer { sqlite3_close(db) }
308

            
309
        var statement: OpaquePointer?
310
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
311
            sqlite3_finalize(statement)
312
            XCTFail("Could not prepare observation query")
313
            return []
314
        }
315
        defer { sqlite3_finalize(statement) }
316

            
317
        var ids: [Int64] = []
318
        while sqlite3_step(statement) == SQLITE_ROW {
319
            ids.append(sqlite3_column_int64(statement, 0))
320
        }
321
        return ids
322
    }
323

            
Bogdan Timofte authored 2 weeks ago
324
    private func sampleVersionDebugRows(at url: URL) throws -> String {
325
        var db: OpaquePointer?
326
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
327
            sqlite3_close(db)
328
            return "could not open database"
329
        }
330
        defer { sqlite3_close(db) }
331

            
332
        let sql = """
333
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
334
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
335
               sr.operating_system_version, v.hk_device_id, v.metadata_id
336
        FROM sample_versions v
337
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
338
        LEFT JOIN sources src ON src.id = sr.source_id
339
        ORDER BY v.id
340
        """
341
        var statement: OpaquePointer?
342
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
343
            sqlite3_finalize(statement)
344
            return "could not prepare version debug query"
345
        }
346
        defer { sqlite3_finalize(statement) }
347

            
348
        var rows: [String] = []
349
        while sqlite3_step(statement) == SQLITE_ROW {
350
            rows.append((0..<13).map { index in
351
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
352
                    return "null"
353
                }
354
                if let text = sqlite3_column_text(statement, Int32(index)) {
355
                    return String(cString: text)
356
                }
357
                return "\(sqlite3_column_double(statement, Int32(index)))"
358
            }.joined(separator: "|"))
359
        }
360
        return rows.joined(separator: "\n")
361
    }
362
}