HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
246 lines | 10.81kb
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
    private func databaseURL() -> URL {
134
        temporaryDirectory.appending(path: "Archive.sqlite")
135
    }
136

            
137
    private func createPrototypeDatabase(at url: URL) throws {
138
        var db: OpaquePointer?
139
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
140
            sqlite3_close(db)
141
            XCTFail("Could not create prototype database")
142
            return
143
        }
144
        defer { sqlite3_close(db) }
145
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
146
        XCTAssertEqual(status, SQLITE_OK)
147
    }
148

            
149
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
150
        makeStepCountSample(value: 42, start: 1_000)
151
    }
152

            
153
    private func makeStepCountSample(value: Double, start: TimeInterval) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
154
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
155
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
156
        let startDate = Date(timeIntervalSince1970: start)
157
        let endDate = Date(timeIntervalSince1970: start + 300)
158
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
159
    }
160

            
161
    private func countRows(in tableName: String, at url: URL) throws -> Int {
162
        var db: OpaquePointer?
163
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
164
            sqlite3_close(db)
165
            XCTFail("Could not open test database")
166
            return 0
167
        }
168
        defer { sqlite3_close(db) }
169

            
170
        var statement: OpaquePointer?
171
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
172
            sqlite3_finalize(statement)
173
            XCTFail("Could not prepare count query")
174
            return 0
175
        }
176
        defer { sqlite3_finalize(statement) }
177

            
178
        guard sqlite3_step(statement) == SQLITE_ROW else {
179
            return 0
180
        }
181
        return Int(sqlite3_column_int(statement, 0))
182
    }
183

            
Bogdan Timofte authored 2 weeks ago
184
    private func observationIDs(at url: URL) throws -> [Int64] {
185
        var db: OpaquePointer?
186
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
187
            sqlite3_close(db)
188
            XCTFail("Could not open test database")
189
            return []
190
        }
191
        defer { sqlite3_close(db) }
192

            
193
        var statement: OpaquePointer?
194
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
195
            sqlite3_finalize(statement)
196
            XCTFail("Could not prepare observation query")
197
            return []
198
        }
199
        defer { sqlite3_finalize(statement) }
200

            
201
        var ids: [Int64] = []
202
        while sqlite3_step(statement) == SQLITE_ROW {
203
            ids.append(sqlite3_column_int64(statement, 0))
204
        }
205
        return ids
206
    }
207

            
Bogdan Timofte authored 2 weeks ago
208
    private func sampleVersionDebugRows(at url: URL) throws -> String {
209
        var db: OpaquePointer?
210
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
211
            sqlite3_close(db)
212
            return "could not open database"
213
        }
214
        defer { sqlite3_close(db) }
215

            
216
        let sql = """
217
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
218
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
219
               sr.operating_system_version, v.hk_device_id, v.metadata_id
220
        FROM sample_versions v
221
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
222
        LEFT JOIN sources src ON src.id = sr.source_id
223
        ORDER BY v.id
224
        """
225
        var statement: OpaquePointer?
226
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
227
            sqlite3_finalize(statement)
228
            return "could not prepare version debug query"
229
        }
230
        defer { sqlite3_finalize(statement) }
231

            
232
        var rows: [String] = []
233
        while sqlite3_step(statement) == SQLITE_ROW {
234
            rows.append((0..<13).map { index in
235
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
236
                    return "null"
237
                }
238
                if let text = sqlite3_column_text(statement, Int32(index)) {
239
                    return String(cString: text)
240
                }
241
                return "\(sqlite3_column_double(statement, Int32(index)))"
242
            }.joined(separator: "|"))
243
        }
244
        return rows.joined(separator: "\n")
245
    }
246
}