HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
551 lines | 25.982kb
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 {
Bogdan Timofte authored 2 weeks ago
7
    private final class AsyncResultBox<T>: @unchecked Sendable {
8
        var result: Result<T, Error>?
9
    }
10

            
Bogdan Timofte authored 2 weeks ago
11
    private var temporaryDirectory: URL!
12

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

            
19
    override func tearDownWithError() throws {
20
        if let temporaryDirectory {
21
            try? FileManager.default.removeItem(at: temporaryDirectory)
22
        }
23
        temporaryDirectory = nil
24
    }
25

            
26
    func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
27
        let store = SQLiteHealthArchiveStore(databaseURL: databaseURL())
28

            
29
        let report = try await store.checkIntegrity()
30
        let records = try await store.records(for: HealthArchiveRecordRequest(
31
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
32
        ))
33

            
34
        XCTAssertTrue(report.passed)
35
        XCTAssertEqual(report.schemaVersion, 2)
36
        XCTAssertEqual(report.sqliteIntegrityStatus, "ok")
37
        XCTAssertEqual(report.foreignKeyIssueCount, 0)
38
        XCTAssertTrue(report.missingTableNames.isEmpty)
39
        XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
40
        XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
41
        XCTAssertTrue(records.isEmpty)
42
    }
43

            
44
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
45
        let url = databaseURL()
46
        try createPrototypeDatabase(at: url)
47
        let store = SQLiteHealthArchiveStore(databaseURL: url)
48

            
49
        let report = try await store.checkIntegrity()
50

            
51
        XCTAssertTrue(report.passed)
52
        XCTAssertEqual(report.schemaVersion, 2)
53
        XCTAssertTrue(report.missingTableNames.isEmpty)
54
    }
55

            
56
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
57
        let url = databaseURL()
58
        let store = SQLiteHealthArchiveStore(databaseURL: url)
59
        let sample = makeStepCountSample()
60

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

            
69
        XCTAssertEqual(firstWrite.insertedCount, 1)
70
        XCTAssertEqual(firstWrite.updatedCount, 0)
71
        XCTAssertEqual(firstWrite.unchangedCount, 0)
72
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
73
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
74
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1)
75
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
76
        XCTAssertFalse(try tableExists("archive_samples", at: url))
Bogdan Timofte authored 2 weeks ago
77
        XCTAssertEqual(secondWrite.insertedCount, 0)
78
        XCTAssertEqual(secondWrite.updatedCount, 0)
79
        XCTAssertEqual(secondWrite.unchangedCount, 1)
80
        XCTAssertEqual(records.count, 1)
81
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
82
        XCTAssertTrue(report.passed)
83
    }
84

            
Bogdan Timofte authored 2 weeks ago
85
    func testVerificationUsesArchiveV2TablesWithoutLegacyMirror() async throws {
86
        let url = databaseURL()
87
        let store = SQLiteHealthArchiveStore(databaseURL: url)
88
        let sample = makeStepCountSample()
89

            
90
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
91
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
92
        let observationIDs = try observationIDs(at: url)
93

            
94
        XCTAssertEqual(observationIDs.count, 2)
95
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), 1)
96
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
97
        XCTAssertFalse(try tableExists("archive_samples", at: url))
98
    }
99

            
Bogdan Timofte authored 2 weeks ago
100
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
101
        let url = databaseURL()
102
        let store = SQLiteHealthArchiveStore(databaseURL: url)
103
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
104
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
105
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
106

            
107
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
108
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
109
        try await store.recordDisappearance(
110
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
111
            sampleTypeIdentifier: typeIdentifier,
112
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
113
        )
114
        let observationIDs = try observationIDs(at: url)
115
        XCTAssertEqual(observationIDs.count, 3)
116

            
117
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
118
            fromObservationID: observationIDs[0],
119
            toObservationID: observationIDs[1],
120
            sampleTypeIdentifier: typeIdentifier
121
        ))
122
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
123
            fromObservationID: observationIDs[0],
124
            toObservationID: observationIDs[1],
125
            sampleTypeIdentifier: typeIdentifier,
126
            kind: .appeared,
127
            limit: 10
128
        ))
129
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
130
            fromObservationID: observationIDs[1],
131
            toObservationID: observationIDs[2],
132
            sampleTypeIdentifier: typeIdentifier
133
        ))
134
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
135
            fromObservationID: observationIDs[1],
136
            toObservationID: observationIDs[2],
137
            sampleTypeIdentifier: typeIdentifier,
138
            kind: .disappeared,
139
            limit: 10
140
        ))
141

            
142
        XCTAssertEqual(appearedSummary.appearedCount, 1)
143
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
144
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
145
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
146
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
147
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
148
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
149
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
150
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
151
    }
152

            
Bogdan Timofte authored 2 weeks ago
153
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
154
        let url = databaseURL()
155
        let store = SQLiteHealthArchiveStore(databaseURL: url)
156
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
157
        let initialCount = 1_200
158
        let appearedCount = 180
159
        let disappearedCount = 160
160
        let pageSize = 25
161
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
162
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
163

            
164
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
165
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
166
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
167
            try await store.recordDisappearance(
168
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
169
                sampleTypeIdentifier: typeIdentifier,
170
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
171
            )
172
        }
173

            
174
        let observationIDs = try observationIDs(at: url)
175
        let firstObservationID = try XCTUnwrap(observationIDs.first)
176
        let lastObservationID = try XCTUnwrap(observationIDs.last)
177
        let queryStartedAt = Date()
178
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
179
            fromObservationID: firstObservationID,
180
            toObservationID: lastObservationID,
181
            sampleTypeIdentifier: typeIdentifier
182
        ))
183
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
184
            fromObservationID: firstObservationID,
185
            toObservationID: lastObservationID,
186
            sampleTypeIdentifier: typeIdentifier,
187
            kind: .appeared,
188
            limit: pageSize
189
        ))
190
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
191
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
192
            fromObservationID: firstObservationID,
193
            toObservationID: lastObservationID,
194
            sampleTypeIdentifier: typeIdentifier,
195
            kind: .appeared,
196
            afterCursor: RecordCursor(
197
                startDate: firstPageLastRecord.startDate,
198
                strictFingerprint: firstPageLastRecord.strictFingerprint
199
            ),
200
            limit: pageSize
201
        ))
202
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
203

            
204
        XCTAssertEqual(summary.appearedCount, appearedCount)
205
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
206
        XCTAssertEqual(summary.representationChangedCount, 0)
207
        XCTAssertEqual(firstPage.count, pageSize)
208
        XCTAssertEqual(secondPage.count, pageSize)
209
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
210
        XCTAssertLessThan(queryElapsedSeconds, 10)
211
    }
212

            
Bogdan Timofte authored 2 weeks ago
213
    func testLargeSyntheticDiffQueryMetrics() throws {
214
        let url = databaseURL()
215
        let store = SQLiteHealthArchiveStore(databaseURL: url)
216
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
217
        let initialCount = 900
218
        let appearedCount = 120
219
        let disappearedCount = 100
220
        let pageSize = 25
221
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
222
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
223

            
224
        try waitForArchiveOperation {
225
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
226
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
227
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
228
                try await store.recordDisappearance(
229
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
230
                    sampleTypeIdentifier: typeIdentifier,
231
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
232
                )
233
            }
234
        }
235

            
236
        let observationIDs = try observationIDs(at: url)
237
        let firstObservationID = try XCTUnwrap(observationIDs.first)
238
        let lastObservationID = try XCTUnwrap(observationIDs.last)
239
        let options = XCTMeasureOptions()
240
        options.iterationCount = 3
241

            
242
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
243
            do {
244
                let result = try waitForArchiveOperation {
245
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
246
                        fromObservationID: firstObservationID,
247
                        toObservationID: lastObservationID,
248
                        sampleTypeIdentifier: typeIdentifier
249
                    ))
250
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
251
                        fromObservationID: firstObservationID,
252
                        toObservationID: lastObservationID,
253
                        sampleTypeIdentifier: typeIdentifier,
254
                        kind: .appeared,
255
                        limit: pageSize
256
                    ))
257
                    return (summary, records)
258
                }
259
                XCTAssertEqual(result.0.appearedCount, appearedCount)
260
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
261
                XCTAssertEqual(result.1.count, pageSize)
262
            } catch {
263
                XCTFail("Measured archive query failed: \(error)")
264
            }
265
        }
266
    }
267

            
Bogdan Timofte authored 2 weeks ago
268
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
269
        let url = databaseURL()
270
        let store = SQLiteHealthArchiveStore(databaseURL: url)
271
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
272
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
273
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
274

            
275
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
276
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
277
        let observationIDs = try observationIDs(at: url)
278
        XCTAssertEqual(observationIDs.count, 2)
279

            
280
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
281
            fromObservationID: observationIDs[0],
282
            toObservationID: observationIDs[1],
283
            sampleTypeIdentifier: typeIdentifier,
284
            limit: 10
285
        ))
286

            
287
        XCTAssertEqual(rows.count, 1)
288
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
289
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
290
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
291
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
292
        XCTAssertEqual(rows.first?.fromValueSum, 42)
293
        XCTAssertEqual(rows.first?.toValueSum, 49)
294
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
295
    }
296

            
Bogdan Timofte authored 2 weeks ago
297
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
298
        let url = databaseURL()
299
        let store = SQLiteHealthArchiveStore(databaseURL: url)
300
        let sample = makeStepCountSample(value: 42, start: 1_000)
301
        let typeIdentifier = sample.sampleType.identifier
302

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

            
305
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
306
            visibleAtObservationID: nil,
307
            sampleTypeIdentifier: typeIdentifier,
308
            limit: 10
309
        ))
310

            
311
        XCTAssertEqual(rows.count, 1)
312
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
313
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
314
        XCTAssertEqual(rows.first?.valueSum, 42)
315
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
316
    }
317

            
318
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
319
        let url = databaseURL()
320
        let store = SQLiteHealthArchiveStore(databaseURL: url)
321
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
322

            
323
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
324
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
325
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
326

            
327
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
328
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
329
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
330
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
331

            
332
        let observationIDs = try observationIDs(at: url)
333
        XCTAssertEqual(observationIDs.count, 4)
334

            
335
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
336
            fromObservationID: observationIDs[0],
337
            toObservationID: observationIDs[observationIDs.count - 1],
338
            sampleTypeIdentifier: typeIdentifier
339
        ))
340

            
341
        XCTAssertEqual(rows.count, 1)
342
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
343
        XCTAssertEqual(rows.first?.disappearedCount, 2)
344
        XCTAssertEqual(rows.first?.appearedCount, 1)
345
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
346
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
347
        XCTAssertEqual(rows.first?.fromValueSum, 30)
348
        XCTAssertEqual(rows.first?.toValueSum, 30)
349
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
350
        XCTAssertTrue(rows.first?.sourceCompatible == true)
351
    }
352

            
353
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
354
        let url = databaseURL()
355
        let store = SQLiteHealthArchiveStore(databaseURL: url)
356
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
357

            
358
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
359
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
360
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
361

            
362
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
363
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
364
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
365
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
366

            
367
        let observationIDs = try observationIDs(at: url)
368
        XCTAssertEqual(observationIDs.count, 4)
369

            
370
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
371
            fromObservationID: observationIDs[0],
372
            toObservationID: observationIDs[observationIDs.count - 1],
373
            sampleTypeIdentifier: typeIdentifier
374
        ))
375

            
376
        XCTAssertEqual(rows.count, 1)
377
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
378
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
379
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
380
        XCTAssertEqual(rows.first?.fromValueSum, 30)
381
        XCTAssertEqual(rows.first?.toValueSum, 30)
382
    }
383

            
Bogdan Timofte authored 2 weeks ago
384
    private func databaseURL() -> URL {
385
        temporaryDirectory.appending(path: "Archive.sqlite")
386
    }
387

            
388
    private func createPrototypeDatabase(at url: URL) throws {
389
        var db: OpaquePointer?
390
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
391
            sqlite3_close(db)
392
            XCTFail("Could not create prototype database")
393
            return
394
        }
395
        defer { sqlite3_close(db) }
396
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
397
        XCTAssertEqual(status, SQLITE_OK)
398
    }
399

            
400
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
401
        makeStepCountSample(value: 42, start: 1_000)
402
    }
403

            
Bogdan Timofte authored 2 weeks ago
404
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
405
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
406
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
407
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
408
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
409
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
410
    }
411

            
Bogdan Timofte authored 2 weeks ago
412
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
413
        (0..<count).map { offset in
414
            let index = startIndex + offset
415
            return makeStepCountSample(
416
                value: Double((index % 97) + 1),
417
                start: 10_000 + Double(index * 600)
418
            )
419
        }
420
    }
421

            
Bogdan Timofte authored 2 weeks ago
422
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
423
        let expectation = expectation(description: "archive operation")
424
        let box = AsyncResultBox<T>()
425

            
426
        Task {
427
            do {
428
                box.result = .success(try await operation())
429
            } catch {
430
                box.result = .failure(error)
431
            }
432
            expectation.fulfill()
433
        }
434

            
435
        wait(for: [expectation], timeout: 20)
436
        return try XCTUnwrap(box.result).get()
437
    }
438

            
Bogdan Timofte authored 2 weeks ago
439
    private func countRows(in tableName: String, at url: URL) throws -> Int {
440
        var db: OpaquePointer?
441
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
442
            sqlite3_close(db)
443
            XCTFail("Could not open test database")
444
            return 0
445
        }
446
        defer { sqlite3_close(db) }
447

            
448
        var statement: OpaquePointer?
449
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
450
            sqlite3_finalize(statement)
451
            XCTFail("Could not prepare count query")
452
            return 0
453
        }
454
        defer { sqlite3_finalize(statement) }
455

            
456
        guard sqlite3_step(statement) == SQLITE_ROW else {
457
            return 0
458
        }
459
        return Int(sqlite3_column_int(statement, 0))
460
    }
461

            
Bogdan Timofte authored 2 weeks ago
462
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
463
        var db: OpaquePointer?
464
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
465
            sqlite3_close(db)
466
            XCTFail("Could not open test database")
467
            return false
468
        }
469
        defer { sqlite3_close(db) }
470

            
471
        let sql = """
472
        SELECT 1
473
        FROM sqlite_master
474
        WHERE type = 'table' AND name = ?
475
        LIMIT 1
476
        """
477
        var statement: OpaquePointer?
478
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
479
            sqlite3_finalize(statement)
480
            XCTFail("Could not prepare table existence query")
481
            return false
482
        }
483
        defer { sqlite3_finalize(statement) }
484

            
485
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
486
        return sqlite3_step(statement) == SQLITE_ROW
487
    }
488

            
Bogdan Timofte authored 2 weeks ago
489
    private func observationIDs(at url: URL) throws -> [Int64] {
490
        var db: OpaquePointer?
491
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
492
            sqlite3_close(db)
493
            XCTFail("Could not open test database")
494
            return []
495
        }
496
        defer { sqlite3_close(db) }
497

            
498
        var statement: OpaquePointer?
499
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
500
            sqlite3_finalize(statement)
501
            XCTFail("Could not prepare observation query")
502
            return []
503
        }
504
        defer { sqlite3_finalize(statement) }
505

            
506
        var ids: [Int64] = []
507
        while sqlite3_step(statement) == SQLITE_ROW {
508
            ids.append(sqlite3_column_int64(statement, 0))
509
        }
510
        return ids
511
    }
512

            
Bogdan Timofte authored 2 weeks ago
513
    private func sampleVersionDebugRows(at url: URL) throws -> String {
514
        var db: OpaquePointer?
515
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
516
            sqlite3_close(db)
517
            return "could not open database"
518
        }
519
        defer { sqlite3_close(db) }
520

            
521
        let sql = """
522
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
523
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
524
               sr.operating_system_version, v.hk_device_id, v.metadata_id
525
        FROM sample_versions v
526
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
527
        LEFT JOIN sources src ON src.id = sr.source_id
528
        ORDER BY v.id
529
        """
530
        var statement: OpaquePointer?
531
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
532
            sqlite3_finalize(statement)
533
            return "could not prepare version debug query"
534
        }
535
        defer { sqlite3_finalize(statement) }
536

            
537
        var rows: [String] = []
538
        while sqlite3_step(statement) == SQLITE_ROW {
539
            rows.append((0..<13).map { index in
540
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
541
                    return "null"
542
                }
543
                if let text = sqlite3_column_text(statement, Int32(index)) {
544
                    return String(cString: text)
545
                }
546
                return "\(sqlite3_column_double(statement, Int32(index)))"
547
            }.joined(separator: "|"))
548
        }
549
        return rows.joined(separator: "\n")
550
    }
551
}