HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
432 lines | 20.682kb
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 testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
134
        let url = databaseURL()
135
        let store = SQLiteHealthArchiveStore(databaseURL: url)
136
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
137
        let initialCount = 1_200
138
        let appearedCount = 180
139
        let disappearedCount = 160
140
        let pageSize = 25
141
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
142
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
143

            
144
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
145
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
146
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
147
            try await store.recordDisappearance(
148
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
149
                sampleTypeIdentifier: typeIdentifier,
150
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
151
            )
152
        }
153

            
154
        let observationIDs = try observationIDs(at: url)
155
        let firstObservationID = try XCTUnwrap(observationIDs.first)
156
        let lastObservationID = try XCTUnwrap(observationIDs.last)
157
        let queryStartedAt = Date()
158
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
159
            fromObservationID: firstObservationID,
160
            toObservationID: lastObservationID,
161
            sampleTypeIdentifier: typeIdentifier
162
        ))
163
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
164
            fromObservationID: firstObservationID,
165
            toObservationID: lastObservationID,
166
            sampleTypeIdentifier: typeIdentifier,
167
            kind: .appeared,
168
            limit: pageSize
169
        ))
170
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
171
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
172
            fromObservationID: firstObservationID,
173
            toObservationID: lastObservationID,
174
            sampleTypeIdentifier: typeIdentifier,
175
            kind: .appeared,
176
            afterCursor: RecordCursor(
177
                startDate: firstPageLastRecord.startDate,
178
                strictFingerprint: firstPageLastRecord.strictFingerprint
179
            ),
180
            limit: pageSize
181
        ))
182
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
183

            
184
        XCTAssertEqual(summary.appearedCount, appearedCount)
185
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
186
        XCTAssertEqual(summary.representationChangedCount, 0)
187
        XCTAssertEqual(firstPage.count, pageSize)
188
        XCTAssertEqual(secondPage.count, pageSize)
189
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
190
        XCTAssertLessThan(queryElapsedSeconds, 10)
191
    }
192

            
Bogdan Timofte authored 2 weeks ago
193
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
194
        let url = databaseURL()
195
        let store = SQLiteHealthArchiveStore(databaseURL: url)
196
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
197
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
198
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
199

            
200
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
201
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
202
        let observationIDs = try observationIDs(at: url)
203
        XCTAssertEqual(observationIDs.count, 2)
204

            
205
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
206
            fromObservationID: observationIDs[0],
207
            toObservationID: observationIDs[1],
208
            sampleTypeIdentifier: typeIdentifier,
209
            limit: 10
210
        ))
211

            
212
        XCTAssertEqual(rows.count, 1)
213
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
214
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
215
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
216
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
217
        XCTAssertEqual(rows.first?.fromValueSum, 42)
218
        XCTAssertEqual(rows.first?.toValueSum, 49)
219
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
220
    }
221

            
Bogdan Timofte authored 2 weeks ago
222
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
223
        let url = databaseURL()
224
        let store = SQLiteHealthArchiveStore(databaseURL: url)
225
        let sample = makeStepCountSample(value: 42, start: 1_000)
226
        let typeIdentifier = sample.sampleType.identifier
227

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

            
230
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
231
            visibleAtObservationID: nil,
232
            sampleTypeIdentifier: typeIdentifier,
233
            limit: 10
234
        ))
235

            
236
        XCTAssertEqual(rows.count, 1)
237
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
238
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
239
        XCTAssertEqual(rows.first?.valueSum, 42)
240
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
241
    }
242

            
243
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
244
        let url = databaseURL()
245
        let store = SQLiteHealthArchiveStore(databaseURL: url)
246
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
247

            
248
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
249
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
250
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
251

            
252
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
253
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
254
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
255
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
256

            
257
        let observationIDs = try observationIDs(at: url)
258
        XCTAssertEqual(observationIDs.count, 4)
259

            
260
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
261
            fromObservationID: observationIDs[0],
262
            toObservationID: observationIDs[observationIDs.count - 1],
263
            sampleTypeIdentifier: typeIdentifier
264
        ))
265

            
266
        XCTAssertEqual(rows.count, 1)
267
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
268
        XCTAssertEqual(rows.first?.disappearedCount, 2)
269
        XCTAssertEqual(rows.first?.appearedCount, 1)
270
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
271
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
272
        XCTAssertEqual(rows.first?.fromValueSum, 30)
273
        XCTAssertEqual(rows.first?.toValueSum, 30)
274
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
275
        XCTAssertTrue(rows.first?.sourceCompatible == true)
276
    }
277

            
278
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
279
        let url = databaseURL()
280
        let store = SQLiteHealthArchiveStore(databaseURL: url)
281
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
282

            
283
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
284
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
285
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
286

            
287
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
288
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
289
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
290
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
291

            
292
        let observationIDs = try observationIDs(at: url)
293
        XCTAssertEqual(observationIDs.count, 4)
294

            
295
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
296
            fromObservationID: observationIDs[0],
297
            toObservationID: observationIDs[observationIDs.count - 1],
298
            sampleTypeIdentifier: typeIdentifier
299
        ))
300

            
301
        XCTAssertEqual(rows.count, 1)
302
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
303
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
304
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
305
        XCTAssertEqual(rows.first?.fromValueSum, 30)
306
        XCTAssertEqual(rows.first?.toValueSum, 30)
307
    }
308

            
Bogdan Timofte authored 2 weeks ago
309
    private func databaseURL() -> URL {
310
        temporaryDirectory.appending(path: "Archive.sqlite")
311
    }
312

            
313
    private func createPrototypeDatabase(at url: URL) throws {
314
        var db: OpaquePointer?
315
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
316
            sqlite3_close(db)
317
            XCTFail("Could not create prototype database")
318
            return
319
        }
320
        defer { sqlite3_close(db) }
321
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
322
        XCTAssertEqual(status, SQLITE_OK)
323
    }
324

            
325
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
326
        makeStepCountSample(value: 42, start: 1_000)
327
    }
328

            
Bogdan Timofte authored 2 weeks ago
329
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
330
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
331
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
332
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
333
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
334
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
335
    }
336

            
Bogdan Timofte authored 2 weeks ago
337
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
338
        (0..<count).map { offset in
339
            let index = startIndex + offset
340
            return makeStepCountSample(
341
                value: Double((index % 97) + 1),
342
                start: 10_000 + Double(index * 600)
343
            )
344
        }
345
    }
346

            
Bogdan Timofte authored 2 weeks ago
347
    private func countRows(in tableName: String, at url: URL) throws -> Int {
348
        var db: OpaquePointer?
349
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
350
            sqlite3_close(db)
351
            XCTFail("Could not open test database")
352
            return 0
353
        }
354
        defer { sqlite3_close(db) }
355

            
356
        var statement: OpaquePointer?
357
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
358
            sqlite3_finalize(statement)
359
            XCTFail("Could not prepare count query")
360
            return 0
361
        }
362
        defer { sqlite3_finalize(statement) }
363

            
364
        guard sqlite3_step(statement) == SQLITE_ROW else {
365
            return 0
366
        }
367
        return Int(sqlite3_column_int(statement, 0))
368
    }
369

            
Bogdan Timofte authored 2 weeks ago
370
    private func observationIDs(at url: URL) throws -> [Int64] {
371
        var db: OpaquePointer?
372
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
373
            sqlite3_close(db)
374
            XCTFail("Could not open test database")
375
            return []
376
        }
377
        defer { sqlite3_close(db) }
378

            
379
        var statement: OpaquePointer?
380
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
381
            sqlite3_finalize(statement)
382
            XCTFail("Could not prepare observation query")
383
            return []
384
        }
385
        defer { sqlite3_finalize(statement) }
386

            
387
        var ids: [Int64] = []
388
        while sqlite3_step(statement) == SQLITE_ROW {
389
            ids.append(sqlite3_column_int64(statement, 0))
390
        }
391
        return ids
392
    }
393

            
Bogdan Timofte authored 2 weeks ago
394
    private func sampleVersionDebugRows(at url: URL) throws -> String {
395
        var db: OpaquePointer?
396
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
397
            sqlite3_close(db)
398
            return "could not open database"
399
        }
400
        defer { sqlite3_close(db) }
401

            
402
        let sql = """
403
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
404
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
405
               sr.operating_system_version, v.hk_device_id, v.metadata_id
406
        FROM sample_versions v
407
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
408
        LEFT JOIN sources src ON src.id = sr.source_id
409
        ORDER BY v.id
410
        """
411
        var statement: OpaquePointer?
412
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
413
            sqlite3_finalize(statement)
414
            return "could not prepare version debug query"
415
        }
416
        defer { sqlite3_finalize(statement) }
417

            
418
        var rows: [String] = []
419
        while sqlite3_step(statement) == SQLITE_ROW {
420
            rows.append((0..<13).map { index in
421
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
422
                    return "null"
423
                }
424
                if let text = sqlite3_column_text(statement, Int32(index)) {
425
                    return String(cString: text)
426
                }
427
                return "\(sqlite3_column_double(statement, Int32(index)))"
428
            }.joined(separator: "|"))
429
        }
430
        return rows.joined(separator: "\n")
431
    }
432
}