HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
165 lines | 7.064kb
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

            
80
    private func databaseURL() -> URL {
81
        temporaryDirectory.appending(path: "Archive.sqlite")
82
    }
83

            
84
    private func createPrototypeDatabase(at url: URL) throws {
85
        var db: OpaquePointer?
86
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
87
            sqlite3_close(db)
88
            XCTFail("Could not create prototype database")
89
            return
90
        }
91
        defer { sqlite3_close(db) }
92
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
93
        XCTAssertEqual(status, SQLITE_OK)
94
    }
95

            
96
    private func makeStepCountSample() -> HKQuantitySample {
97
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
98
        let quantity = HKQuantity(unit: .count(), doubleValue: 42)
99
        let start = Date(timeIntervalSince1970: 1_000)
100
        let end = Date(timeIntervalSince1970: 1_300)
101
        return HKQuantitySample(type: quantityType, quantity: quantity, start: start, end: end)
102
    }
103

            
104
    private func countRows(in tableName: String, at url: URL) throws -> Int {
105
        var db: OpaquePointer?
106
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
107
            sqlite3_close(db)
108
            XCTFail("Could not open test database")
109
            return 0
110
        }
111
        defer { sqlite3_close(db) }
112

            
113
        var statement: OpaquePointer?
114
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
115
            sqlite3_finalize(statement)
116
            XCTFail("Could not prepare count query")
117
            return 0
118
        }
119
        defer { sqlite3_finalize(statement) }
120

            
121
        guard sqlite3_step(statement) == SQLITE_ROW else {
122
            return 0
123
        }
124
        return Int(sqlite3_column_int(statement, 0))
125
    }
126

            
127
    private func sampleVersionDebugRows(at url: URL) throws -> String {
128
        var db: OpaquePointer?
129
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
130
            sqlite3_close(db)
131
            return "could not open database"
132
        }
133
        defer { sqlite3_close(db) }
134

            
135
        let sql = """
136
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
137
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
138
               sr.operating_system_version, v.hk_device_id, v.metadata_id
139
        FROM sample_versions v
140
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
141
        LEFT JOIN sources src ON src.id = sr.source_id
142
        ORDER BY v.id
143
        """
144
        var statement: OpaquePointer?
145
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
146
            sqlite3_finalize(statement)
147
            return "could not prepare version debug query"
148
        }
149
        defer { sqlite3_finalize(statement) }
150

            
151
        var rows: [String] = []
152
        while sqlite3_step(statement) == SQLITE_ROW {
153
            rows.append((0..<13).map { index in
154
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
155
                    return "null"
156
                }
157
                if let text = sqlite3_column_text(statement, Int32(index)) {
158
                    return String(cString: text)
159
                }
160
                return "\(sqlite3_column_double(statement, Int32(index)))"
161
            }.joined(separator: "|"))
162
        }
163
        return rows.joined(separator: "\n")
164
    }
165
}