HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
275 lines | 12.265kb
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
    private func databaseURL() -> URL {
163
        temporaryDirectory.appending(path: "Archive.sqlite")
164
    }
165

            
166
    private func createPrototypeDatabase(at url: URL) throws {
167
        var db: OpaquePointer?
168
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
169
            sqlite3_close(db)
170
            XCTFail("Could not create prototype database")
171
            return
172
        }
173
        defer { sqlite3_close(db) }
174
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
175
        XCTAssertEqual(status, SQLITE_OK)
176
    }
177

            
178
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
179
        makeStepCountSample(value: 42, start: 1_000)
180
    }
181

            
182
    private func makeStepCountSample(value: Double, start: TimeInterval) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
183
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
184
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
185
        let startDate = Date(timeIntervalSince1970: start)
186
        let endDate = Date(timeIntervalSince1970: start + 300)
187
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
188
    }
189

            
190
    private func countRows(in tableName: String, at url: URL) throws -> Int {
191
        var db: OpaquePointer?
192
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
193
            sqlite3_close(db)
194
            XCTFail("Could not open test database")
195
            return 0
196
        }
197
        defer { sqlite3_close(db) }
198

            
199
        var statement: OpaquePointer?
200
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
201
            sqlite3_finalize(statement)
202
            XCTFail("Could not prepare count query")
203
            return 0
204
        }
205
        defer { sqlite3_finalize(statement) }
206

            
207
        guard sqlite3_step(statement) == SQLITE_ROW else {
208
            return 0
209
        }
210
        return Int(sqlite3_column_int(statement, 0))
211
    }
212

            
Bogdan Timofte authored 2 weeks ago
213
    private func observationIDs(at url: URL) throws -> [Int64] {
214
        var db: OpaquePointer?
215
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
216
            sqlite3_close(db)
217
            XCTFail("Could not open test database")
218
            return []
219
        }
220
        defer { sqlite3_close(db) }
221

            
222
        var statement: OpaquePointer?
223
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
224
            sqlite3_finalize(statement)
225
            XCTFail("Could not prepare observation query")
226
            return []
227
        }
228
        defer { sqlite3_finalize(statement) }
229

            
230
        var ids: [Int64] = []
231
        while sqlite3_step(statement) == SQLITE_ROW {
232
            ids.append(sqlite3_column_int64(statement, 0))
233
        }
234
        return ids
235
    }
236

            
Bogdan Timofte authored 2 weeks ago
237
    private func sampleVersionDebugRows(at url: URL) throws -> String {
238
        var db: OpaquePointer?
239
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
240
            sqlite3_close(db)
241
            return "could not open database"
242
        }
243
        defer { sqlite3_close(db) }
244

            
245
        let sql = """
246
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
247
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
248
               sr.operating_system_version, v.hk_device_id, v.metadata_id
249
        FROM sample_versions v
250
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
251
        LEFT JOIN sources src ON src.id = sr.source_id
252
        ORDER BY v.id
253
        """
254
        var statement: OpaquePointer?
255
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
256
            sqlite3_finalize(statement)
257
            return "could not prepare version debug query"
258
        }
259
        defer { sqlite3_finalize(statement) }
260

            
261
        var rows: [String] = []
262
        while sqlite3_step(statement) == SQLITE_ROW {
263
            rows.append((0..<13).map { index in
264
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
265
                    return "null"
266
                }
267
                if let text = sqlite3_column_text(statement, Int32(index)) {
268
                    return String(cString: text)
269
                }
270
                return "\(sqlite3_column_double(statement, Int32(index)))"
271
            }.joined(separator: "|"))
272
        }
273
        return rows.joined(separator: "\n")
274
    }
275
}